// --------------------------------------------------------------------------- // shell/nav.ts — X6: the shell's nav, shaped from `GET /api/v1/nav`. // // WHAT CHANGED, AND WHY IT MATTERS MORE THAN IT LOOKS. The wave-4 nav was a // STATIC array in Shell.tsx — a hand-kept mirror of `core/registry.py` that // nobody's build would notice going stale, and that showed every surface to // everybody. It is now rendered from a SERVER-FILTERED payload: the API applies // `may_open` (incl. `_LEGACY_KEYS` migration-on-read) and drops archived // modules, so what a user sees IS what the server granted. Registry-driven at // one end, permission-filtered at the other. // // ⚠ THERE IS NO CLIENT-SIDE FALLBACK LIST, deliberately. If `/api/v1/nav` // fails, the shell renders an honest empty nav — never a hard-coded one. A // fallback would put surfaces on screen that the server never authorised, which // is precisely the fail-closed rule the wave is built on ("an undeclared // surface is denied"), and it would mask a broken API as a working one. // // No `import.meta` here either: `appBase` is a PARAMETER so this module // compiles and runs under node in `verify_login.py` (see session.ts). // --------------------------------------------------------------------------- import { API_V1, CREDENTIALS } from "../apiContract"; // ⚠ WAVE 19 R8 / C1 — the icon vocabulary is the GRID's, imported not redefined. // A VALUE import (not type-only) because the whitelist has to exist at runtime: // `FolderMark` indexes `FOLDER_SHAPE_PATHS[shape]` and maps the result, so one // unrecognised shape off the wire is `undefined.map` — a white screen, from a // stored preference. `customer-grid/types` imports only `./windows`, which // imports nothing, so this stays runnable under plain node (verify_login // compiles this module and executes it there — see the header note on // `appBase` being a parameter for exactly that reason). import { FOLDER_SHAPES, FOLDER_TONES } from "../customer-grid/types"; import type { FolderIcon } from "../customer-grid/types"; /** One entry of X2's nav payload. `source` is the registry's connector fact * (wave-9 I8: "Sales · Odoo"); `parent` names a sub-module's family head. */ export interface NavPage { key: string; label: string; source?: string; parent?: string; /** * The registry's `group_only`: this key names a FOLDER, never a destination * (`customers` is the only one today — the head of customer_data + cohort). * * ⚠ ADDED BY S1 2026-07-30, additive, present only when true. It supersedes * the client's has-children DERIVATION, which stays as the fallback for a * payload that predates the flag: the registry knows this fact, and a client * re-deriving a fact the server holds is drift waiting to happen. */ group_only?: boolean; /** * `core.perms.nav_pages`' placement flag: `'main'` is the module list, * `'utility'` is a live surface the HOST renders outside it (Analyst, * Settings, the Metric Dictionary). The payload has carried it since X6; * rendering utility rows in the main list was this client's own gap — * `nav_pages`' docstring says "the client places them where the host does". */ chrome?: "main" | "utility"; /** * WAVE 19 R8 / C1 — the mark this database wears, from the tenant-wide * `nav_meta` bucket. Absent ⇒ the default cylinder, which is every database * that has never been given one. * * ⚠ TENANT-WIDE, not per-user, and that is the whole difference between this * and `nav_prefs` two fields down. Folders and placement are one person's * arrangement of their own rail; an icon and a name are what the DATABASE is * called, and a workspace where two people call the same table different * things has no shared vocabulary to hold a conversation in. */ icon?: FolderIcon; /** * WAVE 19 R14 — may THIS session change this row's icon (and, for a `ut_` key, * its name)? The SERVER answers, because it is the only end that can: the * client cannot see who created a user table. * * ⛔ FAIL-CLOSED BY ABSENCE, like every other flag on this payload. A row that * does not say `manage: true` offers no rename and no icon picker — and the * route re-checks regardless, so this is the courtesy half of "the client * hides, the server forbids", never the wall. */ manage?: boolean; /** * WAVE 21 item 6 (ruling R3, contract C3) — may THIS session DELETE this * database? * * ⛔ A SEPARATE FLAG FROM `manage`, deliberately, and reusing that one would * have been the bug. `manage` rides `user_tables.may_open` — which R14 widened * to include everyone a table has been SHARED with (D-32, `user_tables.py:511`). * That is the right reach for renaming and re-iconing; it is far too wide for * a verb that destroys ten artifact families. R3 scopes delete to the CREATOR * or a tenant admin, so the server answers a second, narrower question and * this is where the answer lands. * * Absent ⇒ no, like every flag here. Connector-backed databases never carry * it (R3: "NO delete verb for connector-backed databases — Pause stays their * only off-switch"), so the entry is absent rather than refused. */ canDelete?: boolean; /** * ⭐ WAVE 27 item 3 (contract C9) — IS THIS A **LOCKED DATABASE**? * * The owner's item-4 vocabulary, and it means exactly one thing (DESIGN.md §4, THE THREE * LOCKS): RECORDS cannot be added, deleted or edited — **FIELDS STILL CAN BE**. It is not * "read-only", and a UI that hid the add-field door on the strength of this flag would be a * defect, not caution. * * Answered server-side from `user_tables.records_mutable`'s own condition (`routes_nav.py`), * because the client cannot see a table's `recordMode`. * * ⚠ ABSENT ⇒ UNLOCKED, which is the OPPOSITE direction from `manage`/`canDelete` above and * is deliberate: those two gate CONTROLS, so absence must deny. This one draws a HINT beside a * name, while the real refusal is the route's 403. A store blip should cost a missing padlock, * never a control that silently disappears. */ locked?: boolean; } /** * The wire's icon, validated against the vocabulary the renderer can actually * draw. Anything unrecognised reads as ABSENT (the default mark) rather than as * an error: a stored preference is not worth a broken rail, and the server * applies the same whitelist on the way in, so a mismatch here means the two * ends have drifted — which shows up as an icon quietly reverting, the loudest * safe symptom available. */ export function parseNavIcon(raw: unknown): FolderIcon | undefined { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; const r = raw as { shape?: unknown; tone?: unknown }; if (!(FOLDER_SHAPES as readonly string[]).includes(String(r.shape))) return undefined; if (!(FOLDER_TONES as readonly string[]).includes(String(r.tone))) return undefined; return { shape: r.shape as FolderIcon["shape"], tone: r.tone as FolderIcon["tone"] }; } /** * WAVE 20 item 4 (R8) — the class list for the universal database header's icon * chip: `shell-db-chip`, plus a tone modifier when the database wears a mark. * * R8 asks for "an icon chip on a bold colour background on EVERY database", and * that sentence hides a decision the JSX would otherwise bury: what colour does a * database that never chose one get? Three cases, and they are here rather than * inline so a gate can hold them (and so the answer is written once, not once per * render branch): * * · no icon at all — every built-in and every un-styled table — takes the BASE * chip, which the stylesheet paints in the brand primary. That is the * "sensible default where unset" the ruling asks for, and it is why the base * class carries a colour instead of leaving the chip transparent. * · an icon takes its own TONE at the bold `-deep` weight, so the header agrees * with the mark the same database wears in the rail. * · an UNRECOGNISED tone falls back to the base, never `--unknown`: emitting a * class no stylesheet defines would paint a chip with no background at all, * and a white glyph on white is an invisible header. Same fail-safe posture * as `parseNavIcon` above — a drifted vocabulary reverts to the default mark * rather than breaking the frame. */ export function dbChipClass(icon?: FolderIcon): string { const base = "shell-db-chip"; const tone = icon?.tone; if (!tone || !(FOLDER_TONES as readonly string[]).includes(tone)) return base; return `${base} ${base}--${tone}`; } /** * Read X2's `{pages:[…]}`. An entry with no key or no label is DROPPED rather * than rendered: a nav row with no name is a door with no sign on it, and * guessing the sign from the key would put an internal identifier in the UI. */ export function parsePages(body: unknown): NavPage[] { const raw = (body as { pages?: unknown } | null)?.pages; if (!Array.isArray(raw)) return []; const out: NavPage[] = []; for (const item of raw) { if (!item || typeof item !== "object") continue; const p = item as Record; const key = typeof p.key === "string" ? p.key.trim() : ""; const label = typeof p.label === "string" ? p.label.trim() : ""; if (!key || !label) continue; out.push({ key, label, ...(typeof p.source === "string" && p.source ? { source: p.source } : {}), ...(typeof p.parent === "string" && p.parent ? { parent: p.parent } : {}), ...(p.group_only === true ? { group_only: true } : {}), ...(p.chrome === "main" || p.chrome === "utility" ? { chrome: p.chrome } : {}), ...(parseNavIcon(p.icon) ? { icon: parseNavIcon(p.icon)! } : {}), ...(p.manage === true ? { manage: true } : {}), // C3 (W-5): read with the same `=== true` strictness as `manage` — a // truthy-but-not-true value from a drifted server must not open a delete. ...(p.canDelete === true ? { canDelete: true } : {}), // ⭐ WAVE 27 item 3 / C9 — same `=== true` strictness. A locked database is a padlock // beside a name, so a drifted server sending a truthy string must not paint one. ...(p.locked === true ? { locked: true } : {}), }); } return out; } /** * Split the payload by placement. `main` feeds `shapeNav`; `utility` rows are * placed where the host places them (the Analyst slot above the list, the rest * behind the account menu). An absent flag reads as `main` — a payload that * predates the flag keeps today's behaviour rather than losing rows. */ export function splitChrome(pages: NavPage[]): { main: NavPage[]; utility: NavPage[] } { const main: NavPage[] = []; const utility: NavPage[] = []; for (const p of pages) (p.chrome === "utility" ? utility : main).push(p); return { main, utility }; } /** * WAVE 23 (C10 / R7) — one entry of the Home landing's recents. * * `at` is EPOCH SECONDS, and the type says so because the alternative bit this product once: * a formatted naive-local stamp read by a browser in another zone (D-18). An integer instant * has one reading everywhere, and the two things Home renders from it — "Opened N minutes ago" * and the Today / Past 7 days / Older bucket — are both statements about THE READER'S OWN * CLOCK, which is the one case where using it is correct rather than forbidden (contrast * `alertsModel.stampText`, which must NOT touch a clock because it re-states a server event). */ export interface Recent { key: string; at: number; } /** `empty` (wave 18): the SERVER's reason for an empty page list. `"no_databases"` means a * freshly provisioned tenant with no modules and no databases YET — a legitimate starting * state, not a broken account. Absent means "no reason given", which the shell reads as the * misconfigured-account case it always did. Distinguishing them is what stops the rail saying * "no surfaces are available" beside a hero saying "welcome, create your first database". */ export type NavResult = | { ok: true; pages: NavPage[]; recents: Recent[]; empty?: string; /** * ⭐ W31-T11 (owner item 6b) — REGISTRY KEYS THIS WORKSPACE'S CATALOGUE DOES NOT INCLUDE. * * A DELIBERATE absence. Every provisioned tenant carries a restricted `modules` list * (`gtmlab`/`loopable`/`nurilab` are all `['analyst','automation']`), so this is normal and * the rail draws nothing for these keys — as it always has. The value of naming them is * that it makes the OTHER absence distinguishable. */ omitted?: string[]; /** * ⛔ W31-T11 — PARTS OF THIS PAYLOAD THE SERVER COULD NOT READ. * * `["databases"]` means the `ut_*` merge raised and every database is missing from a * **200 OK**. Before this the two absences were identical on the wire and the shell * rendered `null` for both — which is pixel-identical to still-loading, with no timeout on * `fetchNav` at all. That is the owner's *"Connectors and Automation still disappear"* * report: not a permission bug, an unmarked failure. */ degraded?: string[]; } | { ok: false; status: number }; /** * How long the shell waits for `/nav` before calling it unavailable. * * ⛔ THE POINT IS NOT SPEED, IT IS THAT `phase:"loading"` CAN END. `fetchNav` had no * `AbortSignal`, no deadline and no retry, so a request that never resolved left a rail that * looked *almost* complete — the four static rows plus a spinner — for ever, and the owner read * that screen as modules disappearing. **20 seconds is chosen to sit ABOVE the measured worst * case, not near it**: `/nav` measured a 9.5–14.0 s band on tenant #0 before W31-T10, so a * shorter deadline would have converted a slow success into a manufactured failure. It is a * backstop for "never", not a performance budget. */ export const NAV_TIMEOUT_MS = 20_000; /** Our encoding for "the deadline passed", distinct from 0 (transport) and any HTTP status. */ export const NAV_TIMEOUT_STATUS = -1; /** * `{recents:[…]}` → the list, fail-closed. An entry with no key or an unreadable stamp is * DROPPED rather than rendered at the epoch, which would file it under "Older" for ever and * put a tile on Home for something nobody opened. */ export function parseRecents(body: unknown): Recent[] { const raw = (body as { recents?: unknown } | null)?.recents; if (!Array.isArray(raw)) return []; const out: Recent[] = []; for (const item of raw) { if (!item || typeof item !== "object") continue; const r = item as Record; const key = typeof r.key === "string" ? r.key.trim() : ""; const at = typeof r.at === "number" && isFinite(r.at) ? Math.floor(r.at) : 0; if (!key || at <= 0) continue; out.push({ key, at }); } return out; } /** * WAVE 23 (C10) — stamp a page as opened. FIRE AND FORGET, by design. * * ⛔ IT RETURNS `void` AND SWALLOWS EVERYTHING. A recents stamp is the least important write in * the product: it decorates a landing page. Nothing the user is doing may wait on it, and no * failure of it may reach a screen — a toast saying "we could not record that you opened this * table" is noise about a feature nobody asked for, printed over the table they successfully * opened. The server answers 503 honestly when the store is down; this end simply does not * care, which is the only correct posture for telemetry-shaped state. */ export function postOpened(key: string): void { if (!key) return; void fetch(`${API_V1}/nav/opened`, { method: "POST", credentials: CREDENTIALS, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key }), }).catch(() => {}); } /** * X2 `GET /api/v1/nav`. `status` rides the failure so the caller can tell the * two apart that must not be conflated: **401 means the session is gone** (drop * to the login screen), anything else means the nav is unavailable (stay signed * in, say so). `status: 0` is our encoding for a transport failure, as in * session.ts. */ export function parseKeyList(raw: unknown): string[] { if (!Array.isArray(raw)) return []; const out: string[] = []; for (const k of raw) if (typeof k === "string" && k.trim()) out.push(k.trim()); return out; } export async function fetchNav(timeoutMs: number = NAV_TIMEOUT_MS): Promise { // ⚠ `setTimeout` + `AbortController`, not `AbortSignal.timeout` — the latter is unavailable in // the plain-node context `verify_login.py` compiles and RUNS this module in (see the header // note on `appBase` being a parameter for exactly that reason). A helper that throws on import // in the gate's environment is a helper that silently stops being tested. const ctl = typeof AbortController === "function" ? new AbortController() : null; let timedOut = false; let timer: ReturnType | null = null; // ⛔ THE DEADLINE IS A RACE, NOT ONLY AN ABORT — and this is the shape a probe found the hour // it was written. Aborting the request tells the TRANSPORT to stop; it does not, on its own, // make this function's promise settle. A `fetch` that ignores its signal therefore leaves the // caller awaiting for ever, which is precisely the state the deadline exists to end: the shell // would sit in `phase:"loading"` exactly as it did before, with an abort controller in the // source to prove it had been handled ([[flag-shipped-without-its-writer]]). Racing settles // this promise whatever the transport does, and the abort still fires so the request is not // left running behind it. const deadline = timeoutMs > 0 ? new Promise((resolve) => { timer = setTimeout(() => { timedOut = true; ctl?.abort(); resolve(null); }, timeoutMs); }) : null; try { const call = fetch(`${API_V1}/nav`, { credentials: CREDENTIALS, ...(ctl ? { signal: ctl.signal } : {}), }); const res = deadline ? await Promise.race([call, deadline]) : await call; if (res === null) return { ok: false, status: NAV_TIMEOUT_STATUS }; if (!res.ok) return { ok: false, status: res.status }; const body = await res.json(); const empty = typeof body?.empty === "string" ? body.empty : undefined; const omitted = parseKeyList((body as { omitted?: unknown } | null)?.omitted); const degraded = parseKeyList((body as { degraded?: unknown } | null)?.degraded); return { ok: true, pages: parsePages(body), recents: parseRecents(body), ...(empty ? { empty } : {}), ...(omitted.length ? { omitted } : {}), ...(degraded.length ? { degraded } : {}), }; } catch { // ⛔ THE DEADLINE IS ITS OWN STATUS. A timeout reported as 0 (transport) would be told apart // from a dead network by nothing, and the two want different words on screen: one is "we gave // up waiting", the other is "we could not reach the server at all". return { ok: false, status: timedOut ? NAV_TIMEOUT_STATUS : 0 }; } finally { if (timer !== null) clearTimeout(timer); } } // ── C-SCHEMA (wave 2026-08-02): per-user folders over the database list ───────────────────── export interface NavFolder { id: string; name: string; } /** Per-user, cosmetic, server-validated — placement never grants or hides a surface. */ export interface NavPrefs { folders: NavFolder[]; placement: Record; } export const EMPTY_NAV_PREFS: NavPrefs = { folders: [], placement: {} }; export const MAX_NAV_FOLDERS = 16; /** Mirror of the server's `_clean_nav_prefs` pruning (minus the page-key check — the client * prunes against folders only; a stale page key is harmless and drops on the next save). */ export function parseNavPrefs(body: unknown): NavPrefs { const raw = (body as { prefs?: unknown } | null)?.prefs ?? body; if (!raw || typeof raw !== "object") return EMPTY_NAV_PREFS; const p = raw as { folders?: unknown; placement?: unknown }; const folders: NavFolder[] = []; const seen = new Set(); if (Array.isArray(p.folders)) { for (const f of p.folders.slice(0, MAX_NAV_FOLDERS)) { if (!f || typeof f !== "object") continue; const id = String((f as NavFolder).id ?? "").trim().slice(0, 40); const name = String((f as NavFolder).name ?? "").trim().replace(/\s+/g, " ").slice(0, 40); if (!id || seen.has(id) || !name) continue; seen.add(id); folders.push({ id, name }); } } const placement: Record = {}; if (p.placement && typeof p.placement === "object") { for (const [k, v] of Object.entries(p.placement as Record)) { const fid = String(v ?? "").slice(0, 40); if (k && seen.has(fid)) placement[k.slice(0, 60)] = fid; } } return { folders, placement }; } export async function fetchNavPrefs(): Promise { try { const res = await fetch(`${API_V1}/nav/prefs`, { credentials: CREDENTIALS }); if (!res.ok) return EMPTY_NAV_PREFS; return parseNavPrefs(await res.json()); } catch { return EMPTY_NAV_PREFS; } } /** Wholesale replace, like the table folder stratum. Resolves false on any failure so the * caller can revert its optimistic copy and say so. */ export async function saveNavPrefs(prefs: NavPrefs): Promise { try { const res = await fetch(`${API_V1}/nav/prefs`, { method: "POST", credentials: CREDENTIALS, headers: { "Content-Type": "application/json" }, body: JSON.stringify(prefs), }); return res.ok; } catch { return false; } } // ── WAVE 19 R8 / C1: `nav_meta` — the TENANT-WIDE name + icon overrides ──────────────────── /** One database's overrides. `name` is `ut_*`-only and the SERVER enforces that * (a built-in label is a compiled registry literal); `icon` rides every key. */ export interface NavMetaPatch { icon?: FolderIcon | null; name?: string; } /** * `POST /api/v1/nav/meta`. A PATCH of ONE key, not a wholesale replace — the * opposite posture to `saveNavPrefs` above, and deliberately so. Prefs are one * user's complete picture of their own rail, so replacing it whole is how a * deleted folder stays deleted. `nav_meta` is TENANT-WIDE and every admin edits * the same document: sending a whole picture there means the last writer erases * whatever the previous one named while their tab was open. Explicit `null` * clears the icon; an absent field is untouched, which is what makes a * rename-and-an-icon two independent writes instead of a race. * * Resolves false on any failure so the caller reverts its optimistic copy and * says so, rather than leaving a name on screen that the store never took. */ export async function saveNavMeta(key: string, patch: NavMetaPatch): Promise { try { const res = await fetch(`${API_V1}/nav/meta`, { method: "POST", credentials: CREDENTIALS, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, ...patch }), }); return res.ok; } catch { return false; } } // ───────────────────────────── WAVE 21 item 6 (R3, contract C3): delete a database /** * What deleting this database would actually destroy. * * ⛔ EVERY FIELD IS A COUNT THE USER CAN ACT ON, which is the whole reason the * confirm face exists. R3: "Confirm dialog discloses the footprint (rows/fields/ * views/shared users/bound automations) before DELETE." A dialog that said only * "are you sure?" would be asking about a thing whose size nobody can see — and * the thing that makes a user-table delete different from a view delete is that * nine other artifact families go with it (cohorts, comments, docs + their * dataset bytes, asset refs, grants, nav meta, alerts, automations). * * `automations` are NAMED, not counted, and that asymmetry is deliberate: they * are the only footprint entry that keeps existing after the delete (C3 pauses * them and stamps "target deleted" rather than removing them), so the user has * to be able to go and find them. */ export interface TableFootprint { rows: number; fields: number; views: number; sharedUsers: number; automations: { id: string; name: string }[]; } const intOf = (v: unknown): number => typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0; /** * `GET /api/v1/tables/{key}/footprint` (C3, A serves). * * Returns null when the server would not or could not answer. The caller shows * the confirm face WITHOUT counts in that case rather than substituting zeros: * "0 rows" is a specific, checkable claim about the user's data, and inventing * it to keep a dialog tidy is the fabrication this repo's counts rule forbids * ([[no-unverifiable-aggregates]]). */ export async function fetchTableFootprint(key: string): Promise { try { const res = await fetch(`${API_V1}/tables/${encodeURIComponent(key)}/footprint`, { credentials: CREDENTIALS, }); if (!res.ok) return null; const raw = (await res.json()) as Record | null; if (!raw || typeof raw !== "object") return null; const autos: { id: string; name: string }[] = []; for (const item of Array.isArray(raw.automations) ? raw.automations : []) { if (!item || typeof item !== "object") continue; const a = item as Record; const id = typeof a.id === "string" ? a.id : String(a.id ?? ""); const name = typeof a.name === "string" && a.name.trim() ? a.name.trim() : id; if (id) autos.push({ id, name }); } return { rows: intOf(raw.rows), fields: intOf(raw.fields), views: intOf(raw.views), sharedUsers: intOf(raw.sharedUsers), automations: autos, }; } catch { return null; } } /** * `DELETE /api/v1/tables/{key}` (C3). The route has existed since wave 18 with * NO client caller at all — this is that caller. * * Resolves the server's own message on failure so the rail can say why, and * `true` only on a 2xx. The nav is refetched by the caller either way: a delete * that half-succeeded server-side must not leave a row on screen whose absence * is the only evidence anything happened. */ export async function deleteTable(key: string): Promise<{ ok: boolean; error?: string }> { try { const res = await fetch(`${API_V1}/tables/${encodeURIComponent(key)}`, { method: "DELETE", credentials: CREDENTIALS, }); if (res.ok) return { ok: true }; let detail = ""; try { const body = (await res.json()) as { detail?: unknown; error?: unknown } | null; const d = body?.detail ?? body?.error; if (typeof d === "string") detail = d; } catch { /* a non-JSON error body is not itself an error worth surfacing */ } return { ok: false, error: detail || `The server answered ${res.status}.` }; } catch { return { ok: false, error: "Cannot reach the server." }; } } /** One rendered row of the folded nav: a folder head, or an entry (optionally inside one). */ export type NavRow = | { kind: "folder"; folder: NavFolder; count: number; open: boolean } | { kind: "entry"; entry: NavEntry; folderId?: string }; /** * Fold the shaped entries under the user's folders. A BLOCK (a top-level entry plus its * depth-1 children) moves as one unit — filing a family head files the family. Folders render * in prefs order first, loose blocks after in registry order; a folder with no members still * renders (it was just created — it must exist on screen to receive its first member). * A closed folder contributes only its head row. */ export function foldNav( entries: NavEntry[], prefs: NavPrefs, closed: ReadonlySet ): NavRow[] { interface Block { head: NavEntry; children: NavEntry[]; } const blocks: Block[] = []; for (const e of entries) { if (e.depth > 0 && blocks.length > 0) blocks[blocks.length - 1].children.push(e); else blocks.push({ head: e, children: [] }); } const byFolder = new Map(); const loose: Block[] = []; const ids = new Set(prefs.folders.map((f) => f.id)); for (const b of blocks) { const fid = prefs.placement[b.head.key]; if (fid && ids.has(fid)) { const list = byFolder.get(fid); if (list) list.push(b); else byFolder.set(fid, [b]); } else { loose.push(b); } } const out: NavRow[] = []; const emit = (b: Block, folderId?: string) => { out.push({ kind: "entry", entry: b.head, ...(folderId ? { folderId } : {}) }); for (const c of b.children) out.push({ kind: "entry", entry: c, ...(folderId ? { folderId } : {}) }); }; for (const f of prefs.folders) { const members = byFolder.get(f.id) ?? []; const open = !closed.has(f.id); out.push({ kind: "folder", folder: f, count: members.length, open }); if (open) for (const b of members) emit(b, f.id); } for (const b of loose) emit(b); return out; } // ── C-SCHEMA: the schema drawer payload ───────────────────────────────────────────────────── export interface SchemaField { key: string; label: string; type: string; source: string; description: string; options?: string[]; } export interface SchemaPayload { key: string; label: string; source: string; fields: SchemaField[]; measures: { key: string; label: string; type: string }[]; note?: string; } export function parseSchema(body: unknown): SchemaPayload | null { if (!body || typeof body !== "object") return null; const b = body as Record; if (typeof b.key !== "string" || typeof b.label !== "string") return null; const fields: SchemaField[] = []; if (Array.isArray(b.fields)) { for (const f of b.fields) { if (!f || typeof f !== "object") continue; const r = f as Record; if (typeof r.key !== "string" || typeof r.label !== "string") continue; fields.push({ key: r.key, label: r.label, type: String(r.type ?? ""), source: String(r.source ?? ""), description: String(r.description ?? ""), ...(Array.isArray(r.options) ? { options: r.options.map(String) } : {}), }); } } const measures: SchemaPayload["measures"] = []; if (Array.isArray(b.measures)) { for (const m of b.measures) { if (!m || typeof m !== "object") continue; const r = m as Record; if (typeof r.key !== "string" || !r.key) continue; measures.push({ key: r.key, label: String(r.label ?? r.key), type: String(r.type ?? "") }); } } return { key: b.key, label: b.label, source: String(b.source ?? ""), fields, measures, ...(typeof b.note === "string" && b.note ? { note: b.note } : {}), }; } export async function fetchSchema(key: string): Promise { try { const res = await fetch(`${API_V1}/nav/schema/${encodeURIComponent(key)}`, { credentials: CREDENTIALS, }); if (!res.ok) return null; return parseSchema(await res.json()); } catch { return null; } } /** * What the shell does with an entry: * native — this tree renders it (a hash route) * handoff — a labelled link into the current application (`?page=`) * group — a FOLDER head, not a destination: a label row, never a link */ export type NavKind = "native" | "handoff" | "group"; export interface NavEntry { key: string; label: string; source?: string; kind: NavKind; /** 0 = top level, 1 = a sub-module rail entry (indented, like the app's rail). */ depth: number; /** Absent for a group head — there is nowhere to go. */ href?: string; /** WAVE 19 R8 — the tenant's chosen mark; absent ⇒ the default cylinder. */ icon?: FolderIcon; /** WAVE 19 R14 — the server's answer to "may this session rename/re-icon it". * Absent ⇒ no, and the route refuses independently. */ manage?: boolean; /** WAVE 21 R3/C3 — the server's answer to "may this session DELETE it": the * CREATOR or an admin, never the whole `may_open` set `manage` rides on (see * the long note on `NavPage.canDelete`). Absent ⇒ no. */ canDelete?: boolean; /** ⭐ WAVE 27 item 3 / C9 — a LOCKED database: no new records, fields still fine (see the * long note on `NavPage.locked`). Absent ⇒ unlocked. */ locked?: boolean; /** * ⭐ WAVE 25 (D-54) — THIS ROW IS A SURFACE, NOT A DATABASE. * * Absent ⇒ it is a database (or a folder head over some). Set by `shapeNav` from * `SURFACE_KEYS`, so the fact is decided ONCE, where rows are built, rather than * re-derived by every consumer that happens to remember. * * ⛔ THE DEBT THIS CLOSES IS NOT "Home had a bug", it is that the FILTER WAS A * LOCAL EXPRESSION. The rail has excluded Automation since wave 19 R10 with an * inline `entries.filter(e => e.key !== "automation")`; Home was written against * the raw list a wave later and drew the Automation SURFACE a tile under the * heading "Databases", beside an "Automations" section listing what it contains. * No gate could see it — rendering a tile per granted entry is exactly what that * code was asked to do. A rule every consumer must remember is not a control * ([[rules-need-gates]]), so it became a property of the row plus one accessor. */ surface?: true; } /** * Granted nav rows that are NOT databases. * * ⚠ A CLIENT CAPABILITY FACT, NOT A PERMISSION ONE, exactly like `NATIVE_KEYS` two * screens down: the server decides what this account may open, and this decides * which of those rows a DATABASE list is entitled to draw. A key here that the * server never sends simply never matches. * * One member today. It is a `Set` rather than an `=== "automation"` so the next * non-database surface (an Analyst rebuild, a reports page) joins by adding a key, * in the one place that already carries the reasoning — which is the whole of what * D-54 asked for. */ export const SURFACE_KEYS: ReadonlySet = new Set(["automation"]); /** * A `NavEntry` that has been through {@link databaseEntries} — and the brand is the * point, not decoration. * * ⛔ IT MAKES THE WRONG LIST A COMPILE ERROR RATHER THAN A CODE REVIEW. A component * that draws databases declares `DatabaseEntry[]`, so handing it the raw `entries` * does not type-check and `npx tsc -b` says so on the day it is written. That is the * same lesson this repo learned about optional props (`verify_wiring.py`'s header: * an optional prop degrades to "the feature does not exist"), applied to a LIST: an * unfiltered one degrades to "one extra tile", which nobody notices for a wave. * * ⚠ `DatabaseEntry` IS a `NavEntry`, so anything taking the wider type still accepts * these. The brand only blocks the unsafe direction. */ export type DatabaseEntry = NavEntry & { readonly __isDatabase: true }; /** * The granted nav, minus the surfaces that are not databases. **The only supported * way to obtain a database list** (D-54). * * ⛔ IT NARROWS, IT NEVER WIDENS — `entries` stays the outer bound, so the server's * grant is never re-litigated here. Nothing is added, reordered or renamed; rows are * dropped, and only rows this module has declared to be surfaces. */ export function databaseEntries(entries: NavEntry[]): DatabaseEntry[] { return entries.filter((e) => !e.surface) as DatabaseEntry[]; } /** * The surfaces THIS TREE can actually render: * · `customer_data` / `cohort` — the Customer table, the same `customer-grid` * component the Streamlit embed ships. * * ⚠ This is a CLIENT capability list, not a permission list, and the two must * not be confused. The server decides what a user MAY open; this decides what * the shell KNOWS HOW to draw. A key here that the server did not send is * simply never rendered — the payload is the outer bound, always. * * ⭢ THE NEXT PORTED SURFACE (Collections / Procurement) JOINS BY ADDING ITS KEY * HERE + a server-side builder. That is the whole bet of the Y1 envelope: a * ported page is a registry entry plus a builder, never a new component tree. * `sales` was the proof (EXIT wave 2) and LEFT wave 16 (owner item 7, R3/R11): * the registry row is archived, the builder unregistered, and the owner * rebuilds sales views from the grid's chart/dashboard modes — so the envelope * machinery (PageView, ui/, viz/) is currently a capability with no shipped * page, kept warm by the render-smoke fixture. */ export const NATIVE_KEYS: ReadonlySet = new Set( // `cohort` joined 2026-07-30 and LEFT wave 16 (owner item 5, R10): every cohort now // projects as a LOCKED VIEW in the Customer rail's "Cohorts" section (wave-15 C-LOCK), // so the separate route is gone. The cohort MACHINERY stays — `scope=cohort`, // `workspace.cohortMode`, the events — reachable from the Customer surface. // `product_data` joined wave 16 (C-TOPIC, owner items 9+10): the SAME grid tree over the // SKU catalogue — Shell maps it to `scope="product"` and everything else is the topic's. // `automation` joined wave 18 (C-AUTONAV): SESSION D's surface, its own rail + editor. // User tables are native BY PREFIX (`ut_`), decided in `shapeNav`, not listed here. ["customer_data", "product_data", "automation"]); /** The native keys the Y1 page envelope drives (as opposed to `customer_data`, * which is its own component tree). Kept beside NATIVE_KEYS so the two cannot * drift: everything here MUST also be native, asserted in the gate. EMPTY * since wave 16 (sales retired) — the next builder's key lands here. */ export const ENVELOPE_KEYS: ReadonlySet = new Set([]); /** The app's own deep link (`?page=`) — the strangler hand-off * lands on the exact page rather than a landing screen. */ export function appLink(appBase: string, key: string): string { return `${appBase}/?page=${encodeURIComponent(key)}`; } /** * Payload → rendered rows, in registry order, children under their parent. * * THE GROUP RULE IS DERIVED, NOT RECEIVED. X2's nav item carries no * `group_only` flag, but `customers` IS one (registry: the folder head of * customer_data + cohort — "it has no PAGE_FUNCS entry, the nav never renders * it as a leaf"). So a page that is the `parent` of at least one page IN THE * SAME PAYLOAD renders as a group head. Derived from the contracted payload * alone; if the API later emits an explicit flag, prefer it (posted to S1 — * additive, no amendment). * * ⚠ AN ORPHANED CHILD RENDERS AT TOP LEVEL. If `may_open` granted `cohort` but * not its parent `customers`, the payload has the child and not the head — and * hiding it because its family head is missing would take away a surface the * server explicitly granted. Display never re-litigates a server grant. */ export function shapeNav(pages: NavPage[], appBase: string): NavEntry[] { const present = new Set(pages.map((p) => p.key)); const childrenOf = new Map(); for (const p of pages) { if (!p.parent || !present.has(p.parent)) continue; const list = childrenOf.get(p.parent); if (list) list.push(p); else childrenOf.set(p.parent, [p]); } const entry = (p: NavPage, depth: number): NavEntry => { // Wave 18 (C3-UT): any `ut_`-prefixed key is a USER TABLE — drawable by the same grid // tree (Shell passes the key through as the scope), so it is native BY PREFIX rather // than by membership. The server only emits ut rows this session may open, so this is a // capability rule, never a permission one. const isNative = NATIVE_KEYS.has(p.key) || p.key.startsWith("ut_"); // A native surface stays native even with children — the shell can draw it, // so it is a destination first and a family head second. Otherwise: the // server's flag wins, and has-children is the fallback for a payload that // predates it. const kind: NavKind = isNative ? "native" : p.group_only || childrenOf.has(p.key) ? "group" : "handoff"; return { key: p.key, label: p.label, ...(p.source ? { source: p.source } : {}), ...(p.icon ? { icon: p.icon } : {}), ...(p.manage ? { manage: true } : {}), // C3 (W-5) — carried through `shapeNav` beside `manage`. THE SECOND HALF // OF THE SAME FLAG: `parseNav` reading it and this dropping it would put // the answer on the payload and never on the row the rail renders, which // is precisely how wave 20 shipped four wirings that went nowhere. ...(p.canDelete ? { canDelete: true } : {}), // ⭐ WAVE 27 item 3 / C9 — THE SECOND HALF, for the reason stated directly above: a flag // parsed onto the payload and dropped here reaches no row anybody renders. ...(p.locked ? { locked: true } : {}), // ⭐ D-54 — the databases/surfaces split, stamped at the ONE place rows are built. // Not `kind`: a surface is `native` (this tree renders it) and so are the grids, so the // existing discriminator cannot answer this question and widening it would have made // "can the shell draw it" and "is it a database" one field with two meanings. ...(SURFACE_KEYS.has(p.key) ? { surface: true as const } : {}), kind, depth, ...(kind === "group" ? {} : { href: isNative ? `#/${p.key}` : appLink(appBase, p.key) }), }; }; const out: NavEntry[] = []; for (const p of pages) { // Children are emitted under their head, not in payload position. if (p.parent && present.has(p.parent)) continue; const head = entry(p, 0); const children = childrenOf.get(p.key) ?? []; // ⚠ An EMPTY folder head is dropped. It is not a destination by definition, // so with nothing under it the row says only "here is something you cannot // reach" — which happens to a user granted `customers` but neither child. // This is not re-litigating a grant: a container is not a surface. if (head.kind === "group" && children.length === 0) continue; out.push(head); for (const c of children) out.push(entry(c, 1)); } return out; } /** * The surface the shell lands on, in preference order. * * ⛔ IT IS AN EXPLICIT PREFERENCE, NOT "the first native entry". It used to be * the latter, and the result was that the shell opened on SALES — purely * because `sales` sorts first in the registry — which is the one module the * owner had said to leave alone. "Customer doesn't load on first * initialization" was that, exactly: Customer loaded fine, it just was not what * came up. A landing page is a product decision and belongs in a list somebody * can read, not in whatever the registry happens to order first. */ // (`cohort` left the list with its NATIVE_KEYS exit, wave 16 — a landing must be drawable.) // // ⭐ WAVE 23 (contract C10, ruling R7) — THE LANDING IS NOW `#/home`. // // ⛔ CHROME ROUTES ARE NOT GRANTED PAGES, AND THE :1039 LAW SURVIVES INTACT. This shell's // oldest rule is that the nav is server-filtered and an undeclared surface is denied — which is // why Alerts is a PANEL and not a route. Home and Connectors are routes, and they do not // violate that rule for one reason that has to be true of every future member of this set: // // A CHROME ROUTE RENDERS NOTHING THE SERVER DID NOT ALREADY GRANT. // // Home shows the entries `/nav` returned (its recents are RESOLVED against those entries and a // key absent from them is dropped, `homeModel.groupRecents`) plus create affordances that were // already in the rail. Connectors renders a SERVER-COMPOSED directory (`GET /connectors/ // directory`, session-gated) — it is a window onto a payload, not a surface with its own data. // Neither can show a database, a module or a connector this session may not see; adding a route // that could would be the hard-coded surface the frame refuses to have. export const HOME_ROUTE = "home"; export const CONNECTORS_ROUTE = "connectors"; /** * ⭐⭐ WAVE 32 (ruling R7, contract C3) — INBOX IS THE THIRD CHROME ROUTE, AND IT PASSES THE LAW * ABOVE RATHER THAN BEING EXCEPTED FROM IT. * * `GET /notifications` and `GET /alerts` are session-scoped: they return THIS account's own * notifications about its own views, composed by the server. Like Connectors, the module is a * window onto a payload the server already decided this session may see — it cannot show a * database, a module or another user's notification. So the :1039 law holds unmodified. * * ⛔⛔ AND IT MUST **NOT** BE A `registry.py` MODULE, which is the trap this wave was warned about * by name. Every provisioned tenant carries a restricted `modules` list — `gtmlab`, `loopable` and * `nurilab` are all `['analyst','automation']` — and `/nav` silently OMITS a registry key outside * it. Registering Inbox as a module would have made it invisible in every tenant on the platform * while every gate in the battery stayed green. `alerts` was already chrome (it has no registry * row), so the rename inherits the right shape by construction. */ export const INBOX_ROUTE = "inbox"; /** * ⭐⭐ WAVE 32 (ruling R1, contract C5) — QUERY IS THE FOURTH CHROME ROUTE, and it satisfies the * law above THROUGH ITS REQUIRED PROP rather than despite it. * * `QueryPage` takes `granted: NavEntry[]` — the database entries the shell already holds from * `/nav`, which is to say the set the SERVER decided this session may see. It makes no listing * call of its own, so *"a chrome route renders nothing the server did not already grant"* is true * by construction here rather than by promise, and the AI cannot name a database the caller was * not already given (C5's permission clause, re-used rather than re-implemented). * * ⛔ NOT a `registry.py` module, for the reason stated on `INBOX_ROUTE` above: every tenant's * `modules` list is `['analyst','automation']`, so a registry key would be omitted from the rail * in every tenant while every gate stayed green. */ export const QUERY_ROUTE = "query"; export const CHROME_ROUTES: ReadonlySet = new Set([ HOME_ROUTE, CONNECTORS_ROUTE, INBOX_ROUTE, QUERY_ROUTE, ]); /** * Old route key → the key that replaced it. Applied to the hash before ANYTHING else reads it. * * ⭐ C3: *"the old `alerts` route redirects, it is not deleted — a stored nav preference naming * `alerts` must not orphan."* This is that clause, and it is the RENAME checklist's step 5 * (*"grants and preferences migrate ON READ; never edit stored user records to migrate a * rename"*) expressed for a chrome route. * * ⚠ MEASURED, AND IT CHANGES WHAT THIS IS FOR: **there was no `#/alerts` route to redirect.** * Alerts was deliberately a PANEL — `Shell.tsx`'s bell is a `