| // --------------------------------------------------------------------------- | |
| // 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). | |
| */ | |