File size: 51,837 Bytes
bf8519f 051f280 bf8519f 051f280 bf8519f 051f280 bf8519f 051f280 bf8519f 051f280 bf8519f 051f280 bf8519f 051f280 bf8519f 609fb78 bf8519f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 | // ---------------------------------------------------------------------------
// 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);
}
|