// --------------------------------------------------------------------------- // apiContract.ts — the handful of constants BOTH trees need, and the two // browser-level signals the data layer raises to the frame. // // WHY A ROOT-LEVEL LEAF. `shell/session.ts` and `customer-grid/apiBridge.ts` // both speak X2, so both need the version prefix and the credentials word. The // alternatives were worse: a second copy of `"/api/v1"` is a string that drifts // (this codebase keeps a lock-step GATE for exactly that class of duplication), // and importing shell code from `customer-grid/**` would point the dependency // edge the wrong way — the grid is the ASSET, the shell is the disposable // frame, and the embed bundle must never grow a reason to pull the frame in. // A leaf both import is the only arrangement with no cycle and no drift. // // It imports nothing, by construction. // --------------------------------------------------------------------------- /** X2: every route lives under this prefix. `/api/health` is the one exception * and is not ours — it is the unauthenticated liveness probe. */ export const API_V1 = "/api/v1"; /** * ⚠ EXPLICIT, not defaulted. Same-origin is already the browser default, but * this is the one word that decides whether the X3 session cookie rides the * request at all — and the cookie is HttpOnly, so getting it wrong produces no * client-side symptom, just a 401 from a server that never saw a session. An * implicit default is not something a reader (or a gate) can check. */ export const CREDENTIALS: RequestCredentials = "same-origin"; /** * THE SESSION DIED UNDER US. Raised by any authenticated call that comes back * 401, and handled by the shell (sign out, show the door). * * A DOM CustomEvent rather than an import, deliberately: `customer-grid/**` is * host-neutral and must not know a shell exists — it is the same tree the * Streamlit embed ships. The window is the one channel both sides already * share (`hostBridge` uses it for the host-render signal for the same reason). */ export const UNAUTHORIZED_EVENT = "aios:unauthorized"; /** * ⭐ THE TENANT THIS RESPONSE WAS SERVED FOR — the header that closes a * cross-tenant SIGHTING the 401 path structurally cannot catch. * * ⛔ THE BUG, owner-reported 2026-08-09: *"I am able to see the automation of * tenant Nurilab, when logged into Royal Import's."* MEASURED: it is not a * server leak. Automations live in per-tenant dataset REPOS, `/automations` * reads `session.runtime` = `get_runtime(claims["t"])` off the SIGNED cookie, * and `_user_for` refuses when the cookie's tenant is not the account's. Every * link holds. What does NOT hold is the browser: * * `aios_session` is ONE cookie, `path="/"`, per ORIGIN. So one browser can * hold exactly one tenant session at a time. Sign into tenant B in a second * tab and the FIRST tab is silently repointed — it keeps painting tenant A's * chrome (nav, page, the automations it already fetched) while every new * request it makes is answered for tenant B. Two tenants on one screen, and * the server was right every time. * * ⚠ AND IT IS NOT COSMETIC: a write issued from the stale tab lands in the * OTHER tenant's store, because the cookie decides. That is the same event the * owner reports as "I updated the data and it doesn't register the change" — * it registered, in the wrong tenant. * * `handledUnauthorized` cannot see this: a repointed tab gets 200s, not 401s. * Its own comment already names the neighbouring hazard ("one browser, two * accounts, and a cached book served across the boundary") — this is that rule * one level up, at the TENANT boundary rather than the user one. */ export const TENANT_HEADER = "X-AIOS-Tenant"; /** The tenant this frame BOOTED for; `null` until the first authenticated * answer names one. Module-level on purpose — it must outlive every component * that could be unmounted by the very reset it triggers. */ let bootTenant: string | null = null; /** Test seam ONLY — `verify_login.py` drives the comparator without a browser. */ export function _resetTenantGuard(): void { bootTenant = null; } export function currentTenant(): string | null { return bootTenant; } /** * Compare a response's tenant stamp against the one this frame booted with. * Returns true when it detected a SWAP and handled it. * * ⚠ RELOAD, NEVER A PARTIAL RESET. There is no correct way to re-point a live * frame at another tenant: its nav, its route, its grid caches, its localStorage * bucket and its in-flight requests were all resolved for the old one. A full * reload is the only action that cannot leave two tenants blended, and it lands * the user in the tenant they actually signed into. * * ⚠ An ABSENT header is not a mismatch. Unauthenticated routes and any older * build serve none, and treating absent as "changed" would reload the app in a * loop — the failure mode that would be worse than the bug. */ export function checkTenant(res: { headers: { get(name: string): string | null } }): boolean { let seen: string | null = null; try { seen = res.headers.get(TENANT_HEADER); } catch { return false; } if (!seen) return false; if (bootTenant === null) { bootTenant = seen; return false; } if (bootTenant === seen) return false; bootTenant = seen; try { if (typeof window !== "undefined" && window.location) window.location.reload(); } catch { /* node (the gate) has no window — the comparator is what is under test */ } return true; } /** The authenticated read FAILED for a reason that is not authentication. The * frame says so plainly; it never substitutes sample data (see apiBridge). */ export const DATA_ERROR_EVENT = "aios:data-error"; /** The server sent a human-readable confirmation with a write (X2's `toast`). * `detail` is the string. */ export const TOAST_EVENT = "aios:toast"; /** * owner item 2 (2026-08-03) — the events response carried DERIVED CELLS with it. * * Creating a measure column used to take two sequential round trips before a number appeared: * one to persist the field, one to compute it. The server now computes right after the write * and returns the values on the same response; `detail` is `{[pid: string]: {[key]: value}}`, * exactly the `derived` shape `/workspace` sends. * * ⚠ A SHORTCUT, NEVER A PATH. `WORKSPACE_STALE_EVENT` still fires beside it and the re-read * still delivers the same values — so a browser that misses this, or a server that could not * compute it, behaves exactly as it did before. Nothing may be built on it arriving. */ export const DERIVED_CELLS_EVENT = "aios:derived-cells"; /** A write CHANGED durable workspace state (a cohort's membership, a list add, a * folder move) and the client's copy is now stale. * * ⛔ WHY THIS EXISTS. `/customers` carries rows; the workspace lives at its own * URL. So in the embed the Streamlit host reruns and the left panel repaints, * while standalone had NOTHING — the server's `toast` was the only evidence a * cohort add had happened, and the panel beside it still showed the old * membership. A toast is a receipt, not a refresh. */ export const WORKSPACE_STALE_EVENT = "aios:workspace-stale"; /** Owner item 10 (2026-07-31): the user CLICKED INTO THE WORK SURFACE (a saved view, a grid * cell) — the frame should fold its navigation rail down to the slim strip so the table gets * the width. Raised by the grid, handled by the shell; a no-op in the embed (no listener), * which is exactly the host-neutral contract the other signals follow. */ export const NAV_MINIMIZE_EVENT = "aios:nav-minimize"; /** Wave 18 (C3-UT): rows changed OUTSIDE the grid's own write path — a shell "Add record", * an automation run — and the current topic's rows should be refetched. The grid clears its * rows cache and reloads; senders call `clearCustomersCache()` first so the refetch cannot * be served from the 5-minute memo. */ export const ROWS_STALE_EVENT = "aios:rows-stale"; export function signal(name: string, detail?: unknown): void { if (typeof window === "undefined") return; window.dispatchEvent(new CustomEvent(name, { detail })); } /** * Wave 20 (item 25) — the shell→grid channel for an alert's click-through. * * A notification names `{topic, viewId}`; the SHELL routes to the table and the GRID owns view * selection, so neither has to learn the other's state. An alert for a view the reader can no * longer see must do NOTHING rather than throw — the listener checks its own view list first. */ export const VIEW_OPEN_EVENT = "aios:view-open"; /** `detail` of {@link VIEW_OPEN_EVENT}. */ export interface ViewOpenDetail { topic: string; viewId: string; } /** * WAVE 23 (contract C6, wiring W23-W5) — the shell→automation channel for a review * notification's click-through. * * ⛔ THE SAME SHAPE AS `VIEW_OPEN_EVENT` ABOVE, FOR THE SAME REASON, and wave 20 is why both * exist as constants in this leaf rather than as a string typed twice. A card arriving at a * review stage queues an `automation_review` notification naming `{autoId, stageId, count}`; * clicking it has to do TWO things that live on opposite sides of an ownership fence — route to * `#/automation` (the SHELL's hash) and select that automation in the rail (`AutomationSurface`'s * own `activeId`, which the shell cannot see and must not learn). So the frame navigates and * then ASKS, and the surface answers if it can. * * ⚠ THE LISTENER IS THE HALF THAT CAN BE ABSENT. Wave 20 shipped item 25's click-through with * this exact shape and NO listener — the event was dispatched into nothing, every gate green, * the notification landing the reader on the right table and doing nothing else. Declared here * on the wave's first day precisely so the surface can wire the listener while it is being * built rather than at close-out; the wiring row (W23-W5) asserts both ends. * * An automation the reader can no longer open must do NOTHING rather than throw — the listener * checks its own list first, exactly as the grid does for a view it cannot see. */ export const AUTOMATION_OPEN_EVENT = "aios:automation-open"; /** `detail` of {@link AUTOMATION_OPEN_EVENT}. `stageId` is advisory — a surface that does not * scroll to a stage simply selects the automation. */ export interface AutomationOpenDetail { autoId: string; stageId?: string; /** ⭐ W32 C3: which tab of that automation to land on (`"runs"` for a run-log notification). * Advisory in the same way `stageId` is — a surface that has one tab simply ignores it. */ tab?: string; } /** * ⭐⭐ WAVE 32, CONTRACT C3 (cross-fence wiring 5) — WHERE A NOTIFICATION POINTS. * * `GET /notifications` carries this per item, and it is what makes owner item 19 ("an Inbox item * opens the thing it is about") a data question rather than a pile of branches: the server names * the destination, `inboxModel.routeForTarget` turns it into a surface, and `Shell.tsx` owns only * the hash. * * ⛔ **REQUIRED ON THE WIRE, per C3** — not optional. An optional target degrades to "clicking * does nothing", which is indistinguishable from the feature never having been built and is red * in no gate. That is the same rule `InboxPage.onOpenTarget` carries as a required PROP; the two * halves are one contract and this file is the seam between them. * * ⚠ `module` is a STRING, not a union, and deliberately: this client must be able to RECEIVE a * module it does not know (a server newer than the tab) and say so, rather than fail to parse the * payload. `routeForTarget` returns `null` for an unknown module and the frame shows a sentence — * the "an unknown module answers null and the frame MUST say so" clause. */ export interface NotificationTarget { /** `"database"` | `"automation"` today. See the note above on why this is not a union. */ module: string; /** The database key, or the automation id. */ id: string; /** A view id for a database target; the tab (`"runs"`) for an automation target. */ tab?: string; } /* * ⛔ `AUTOMATION_CREATE_EVENT` STOOD HERE AND IS DELETED WHOLE (wave 25 item 5a, ruling R8). * * It existed for ONE purpose: the "Automated database" doors on Home, in the Database flyout's * create menu and in the New-database dialog routed to `#/automation` and then raised this so the * surface would open its create flow. R8 deletes all three doors — "creating a database is one * act; pointing an automation at it is another" — and `Shell.tsx`'s `openAutomated` was this * event's ONLY signaller. * * ⛔ SO IT HAD TO GO WITH THEM, and the reason is this repo's own scar tissue rather than tidiness. * Left behind, it would be a constant with a listener and no signaller — the exact mirror of the * defect wave 24 found here (declared, signalled, consumed NOWHERE, every gate green, three doors * that navigated and then did nothing). A one-sided event is indistinguishable from a working one * from every direction except a grep, in BOTH directions, so the fix is symmetric: delete the side * that is left, never leave the half that compiles. * * ⚠ `AUTOMATION_OPEN_EVENT` above is UNAFFECTED and still two-sided (the shell signals it from * Home's automation tiles and the alerts click-through; `AutomationSurface` listens at module * scope). `verify_automation_ui.py` now DERIVES that rule instead of naming these two constants: * every `AUTOMATION_*_EVENT` declared in this file must have a signal site AND a listener site. * Creating an automation has one door and it is on the automation surface, which is where W24 put * the front door anyway (`AutomationSurface`'s rail button and its empty state). */