| // --------------------------------------------------------------------------- | |
| // 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<string, unknown>; | |
| 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<string, unknown>; | |
| 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<NavResult> { | |
| // β `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<typeof setTimeout> | 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<null>((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<string, string>; | |
| } | |
| 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<string>(); | |
| 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<string, string> = {}; | |
| if (p.placement && typeof p.placement === "object") { | |
| for (const [k, v] of Object.entries(p.placement as Record<string, unknown>)) { | |
| 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<NavPrefs> { | |
| 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<boolean> { | |
| 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<boolean> { | |
| 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<TableFootprint | null> { | |
| 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<string, unknown> | 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<string, unknown>; | |
| 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<string> | |
| ): 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<string, Block[]>(); | |
| 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<string, unknown>; | |
| 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<string, unknown>; | |
| 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<string, unknown>; | |
| 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<SchemaPayload | null> { | |
| 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=<key>`) | |
| * 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<string> = 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<string> = new Set<string>( | |
| // `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<string> = new Set<string>([]); | |
| /** The app's own deep link (`?page=<registry key>`) β 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<string, NavPage[]>(); | |
| 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<string> = 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 `<button>`, and the comment beside it | |
| * says a client-invented `#/alerts` route *"would be exactly the hard-coded surface the frame | |
| * refuses to have"*. So no user has a stored `alerts` placement, and the clause was written | |
| * against a route that never existed. It is kept anyway, as forward insurance rather than | |
| * archaeology: `#/alerts` is the hash a person would GUESS, type, or have bookmarked from a | |
| * notification email, and answering it costs one map lookup. Retiring it would need evidence | |
| * nobody types it, which is not obtainable. | |
| */ | |
| export const LEGACY_ROUTES: Readonly<Record<string, string>> = { alerts: INBOX_ROUTE }; | |
| /** The current key for a hash that may name a retired one. Identity for everything else. */ | |
| export function canonicalRoute(route: string): string { | |
| return LEGACY_ROUTES[route] ?? route; | |
| } | |
| export const LANDING_PREFERENCE: readonly string[] = [HOME_ROUTE]; | |
| /** | |
| * The surface the shell lands on. | |
| * | |
| * β THE THREE LANDING SITES MOVE TOGETHER OR LOGINS STRAND (C10's own warning): this function, | |
| * `LANDING_PREFERENCE` above, and the post-login redirect in `Shell.tsx`. Half a move leaves a | |
| * signed-in user on a hash nothing renders. | |
| * | |
| * A chrome route needs no entry to be reachable β that is what makes it chrome β so the loop | |
| * below returns it without consulting `entries`, and the old preference chain survives beneath | |
| * it for the day a granted page becomes the landing again. `entries` stays a parameter for the | |
| * same reason: the fallback is still a real answer about a real payload. | |
| */ | |
| export function defaultRoute(entries: NavEntry[]): string { | |
| return firstDestination(entries, LANDING_PREFERENCE); | |
| } | |
| /** | |
| * The landing rule itself, over an arbitrary preference list. | |
| * | |
| * β EXTRACTED (wave 23) SO THE FALLBACK CHAIN STAYS UNDER TEST. Before C10 the chain WAS | |
| * `defaultRoute`, so `shell.test.ts` asserted it by calling that. With `home` at the head of the | |
| * preference the function now returns on its first line, and the whole "preferred native entry β | |
| * any native entry β any destination" ladder became unreachable from the outside β three checks | |
| * would have had to be deleted rather than retargeted, which is how a gate quietly stops | |
| * asserting the thing it was written for ([[gate-can-report-green-on-nothing]]). It is one | |
| * function called two ways instead. | |
| */ | |
| export function firstDestination( | |
| entries: NavEntry[], | |
| preference: readonly string[] | |
| ): string { | |
| for (const key of preference) { | |
| // A chrome route needs no entry to be reachable β that is what makes it chrome. | |
| if (CHROME_ROUTES.has(key)) return key; | |
| const hit = entries.find((e) => e.key === key && e.kind === "native"); | |
| if (hit) return hit.key; | |
| } | |
| return ( | |
| entries.find((e) => e.kind === "native")?.key ?? | |
| entries.find((e) => e.kind !== "group")?.key ?? | |
| "" | |
| ); | |
| } | |
| /** | |
| * Resolve a hash route against the nav. | |
| * | |
| * A GROUP is never a destination β a hand-typed `#/customers` resolves to the | |
| * family's first member, mirroring what the app itself does with a stale | |
| * `?page=customers` deep link ("redirects to the family's first visible | |
| * member"). An unknown route falls to the default. Both fall back rather than | |
| * erroring, because a URL is something a person can type or a bookmark can | |
| * outlive. | |
| * | |
| * β WAVE 23 (C10): the fallback is now `#/home`, which is CHROME and therefore matches no | |
| * entry β so this returns `undefined` for an unknown route, and the frame reads "no entry" as | |
| * "render Home". One behaviour change, stated plainly: `#/deleted_table` used to open the | |
| * Customer grid and now opens Home. That is the better answer (the reader asked for something | |
| * that is gone; the landing is where you choose again) and it is the SAME answer an empty hash | |
| * gets β which is what "Home is the default" has to mean in order to be true. | |
| */ | |
| export function resolveRoute(entries: NavEntry[], route: string): NavEntry | undefined { | |
| const i = entries.findIndex((e) => e.key === route); | |
| if (i >= 0) { | |
| const hit = entries[i]; | |
| if (hit.kind !== "group") return hit; | |
| // A group head: fall through to its first real member β the rows that | |
| // follow it DEEPER, stopping at the next row of its own level or shallower. | |
| for (const e of entries.slice(i + 1)) { | |
| if (e.depth <= hit.depth) break; | |
| if (e.kind !== "group") return e; | |
| } | |
| } | |
| const fallback = defaultRoute(entries); | |
| return entries.find((e) => e.key === fallback); | |
| } | |