File size: 13,834 Bytes
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 | // ---------------------------------------------------------------------------
// 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).
*/
|