loopable / web /src /shell /nav.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
609fb78 verified
Raw
History Blame Contribute Delete
51.8 kB
// ---------------------------------------------------------------------------
// 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);
}