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