import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { GATEWAY_REFUSED_EVENT } from '../utils/gatewayRefusal'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; import { useAuthServiceContext } from './AuthService.tsx'; import { tokenNamesPractice } from '../utils/agencyGrant'; /** * WHICH TENANT AM I TALKING TO - production, or the sandbox? * * THE ONE RULE THIS FILE EXISTS TO ENFORCE: THE ANSWER COMES FROM THE API, NEVER FROM LOCAL CONFIG. * * A sandbox build differs from a production build only by the `stack` object baked into * `environments/environment.ts` - a different pool, different API hosts. So `environment.sandbox === true` * would be one line, and it is exactly the shape this card forbids: a build-time flag, an env var and a * hostname check all fail the same way - **the client decides, and the client can be wrong.** * * WHY ASKING THE API IS DIFFERENT IN KIND RATHER THAN JUST BETTER: the sandbox is a SEPARATE * DEPLOYMENT. A production API has no code path that can answer `sandbox: true`, and a sandbox API has * none that answers `false`. The thing that decides is not in the browser at all, which is what makes * both directions of the second control hold - a production session cannot be talked into showing the * banner, and a sandbox session cannot be talked into hiding it. * * THE TOKEN'S `iss` CLAIM WAS THE OTHER CANDIDATE AND IS REJECTED. It is unforgeable, but it names the * POOL - so the client would still be the one interpreting which pool means sandbox, from a mapping that * lives in the bundle. That is local config wearing a signature. * * AND THE THIRD STATE IS NOT A CONVENIENCE. There is no safe default when the answer cannot be read: * a production user told they are in a sandbox may treat real work as disposable, and a sandbox user told * nothing may treat test data as real. The card names both directions as harmful, so `unknown` is a real * answer that the banner renders honestly rather than resolving to either claim. * A `?? false` on this read is the defect this whole card exists to prevent, and it is the natural * thing to type. */ // Declared on the ROOT api, the gateway every session already reaches before it has any organization // context - this question is asked before a workspace is chosen. // An undeclared path does not 404 readably: the preflight is refused, the browser blocks the response, // and no status ever reaches JavaScript, so the screen can only say "failed to load". const API_URL = environment.apiUrl; /** * `unknown` is the state before the answer arrives AND the state after it fails. They are deliberately * the same value: a screen that distinguished "still asking" from "could not tell" would have to pick a * banner for one of them, and the honest answer for both is that we do not yet know. */ export type TenantKind = 'production' | 'sandbox' | 'unknown'; export interface TenantAnswer { kind: TenantKind; /** False while a signed-in read is in flight: an unsettled `unknown` is "checking", not a failure. */ settled?: boolean; /** Asks again, for the refusal's Try again. */ retry?: () => void; /** * The gateway itself refused this identity at this practice: signed in, but not on its staff (NP-5, * Captain 2026-10-02 - a deactivated member still signs in, because the same identity may work elsewhere). * Still `unknown` - the mode was not read - but a Try again would fail the same way forever. */ noAccess?: boolean; /** Refused, and the token names no current role at this practice: signing in again cannot help. */ accessEnded?: boolean; } /** * Is this the gateway's own refusal of the identity, rather than a failure to answer? * * `/tenant` admits every signed-in member, so the authorizer denies it for one reason: the caller has no * membership here. Keyed on `x-amzn-errortype`, which API Gateway sets on what it writes and our handlers * never do (HttpService), not on the sentence. A 401 is an expired session, not lost access. */ const isNoMembershipRefusal = (error: unknown): boolean => { const { status, awsErrorType } = (error ?? {}) as { status?: unknown; awsErrorType?: unknown }; return status === 403 && typeof awsErrorType === 'string' && awsErrorType.trim() !== ''; }; export const useTenantService = () => { const { get } = useHttpService(); // Read through a ref: the auth context value is not promised stable, and fetchTenant keys effects. const authContext = useAuthServiceContext(); const auth = useRef(authContext); auth.current = authContext; /** * Ask the API what it is. * * IT NEVER THROWS AND IT NEVER GUESSES. A rejection resolves to `unknown`, which the banner renders * as its own state. Throwing would push the decision to a caller who has no better information, and * defaulting would be the failure above. */ const fetchTenant = useCallback(async (): Promise => { try { const body = await get<{ sandbox?: unknown }>(`${API_URL}/tenant`); // STRICTLY BOOLEAN. A missing or non-boolean field is NOT "false" - it is an API that did // not answer the question, which is exactly the case `unknown` exists for. Coercing here is // how a sandbox session would come to show no banner. if (body?.sandbox === true) return { kind: 'sandbox' }; if (body?.sandbox === false) return { kind: 'production' }; return { kind: 'unknown' }; } catch (error) { if (!isNoMembershipRefusal(error)) return { kind: 'unknown' }; const { getValidToken, wpOrganization } = auth.current; const token = await (async () => getValidToken?.())().catch(() => null); const accessEnded = tokenNamesPractice(token, wpOrganization?.current?.id) === false; return { kind: 'unknown', noAccess: true, ...(accessEnded ? { accessEnded } : {}) }; } }, [get]); return { fetchTenant }; }; /** The API said which mode this is, which it only does for a member it admits. */ const isAdmitted = (answer: TenantAnswer): boolean => answer.kind === 'sandbox' || answer.kind === 'production'; /** How long a quiet re-check waits before asking /tenant a second time to confirm a refusal. */ export const tenantRecheckTiming = { confirmMs: 5_000 }; const TenantContext = createContext(undefined); export const TenantServiceProvider = ({ children }: { children: ReactNode }) => { const { fetchTenant } = useTenantService(); const { currentUser } = useAuthServiceContext(); const [answer, setAnswer] = useState({ kind: 'unknown', settled: !currentUser }); const [attempt, setAttempt] = useState(0); const retry = useCallback(() => setAttempt((n) => n + 1), []); /* * GATED ON THE SIGNED-IN IDENTITY, the same way CapabilityService gates `/my-capabilities` on * `currentUser` - see its header. This provider mounts at the app root, above the login page, so an * unconditional fetch-on-mount asks `getValidToken` for a token that does not exist: that ends in * `logout()` and a `window.location.href` change, which is a reload loop for every signed-out * visitor. `fhir.id` is the identity every caller class carries; `cognito.sub` is the fallback for * the moment a session is authenticated a beat before its FHIR resource has resolved. */ const userKey = currentUser?.fhir?.id ?? currentUser?.cognito?.sub ?? null; useEffect(() => { if (!userKey) { // No signed-in identity to ask on behalf of - stay `unknown` rather than asking, and rather // than guessing. setAnswer({ kind: 'unknown', settled: true }); return; } let live = true; setAnswer({ kind: 'unknown', settled: false }); void (async () => { /* * CAUGHT HERE TOO, and it is not belt-and-braces. `fetchTenant` catches what the request * does, but `get` itself can throw SYNCHRONOUSLY - a malformed URL, a hook that is not what * it was expected to be - and that escapes before any promise exists. An effect that can * throw is a provider that can take the app down, and the state it would take it down over * is the one it already has an honest answer for. */ let next: TenantAnswer; try { next = await fetchTenant(); } catch { next = { kind: 'unknown' }; } if (!live) return; setAnswer({ ...next, settled: true }); if (!next.noAccess) return; // A member who has just joined can be refused once while the gateway catches up with the join, // so a refusal is asked again once, quietly; an admitted answer then replaces the no-access screen. await new Promise((resolve) => setTimeout(resolve, tenantRecheckTiming.confirmMs)); if (!live) return; const again = await fetchTenant().catch((): TenantAnswer => ({ kind: 'unknown' })); if (live && isAdmitted(again)) setAnswer({ ...again, settled: true }); })(); return () => { live = false; }; }, [userKey, fetchTenant, attempt]); // A member removed mid-session: their next requests are refused by the gateway, but the answer above was // fetched once for this identity. Each refusal asks /tenant again, QUIETLY - the workspace stays up while // asking - and only a refused /tenant changes the screen. At most once in 30s: most refusals are routes // the role simply may not use. const lastRecheck = useRef(0); useEffect(() => { if (!userKey) return; let live = true; const recheck = () => { const at = Date.now(); if (at - lastRecheck.current < 30_000) return; lastRecheck.current = at; void (async () => { const first = await fetchTenant(); if (!live) return; if (!first.noAccess) { // A later admitted answer lifts a refusal already on screen. if (isAdmitted(first)) setAnswer((current) => (current.noAccess ? { ...first, settled: true } : current)); return; } // CONFIRMED BEFORE IT SWITCHES: a transient authorizer deny must not lock an active member out // until they reload. Only a second refusal a few seconds later puts up the no-access screen. await new Promise((resolve) => setTimeout(resolve, tenantRecheckTiming.confirmMs)); if (!live) return; const second = await fetchTenant(); if (!live) return; if (second.noAccess) setAnswer({ ...second, settled: true }); else if (isAdmitted(second)) setAnswer((current) => (current.noAccess ? { ...second, settled: true } : current)); })().catch(() => undefined); }; window.addEventListener(GATEWAY_REFUSED_EVENT, recheck); return () => { live = false; window.removeEventListener(GATEWAY_REFUSED_EVENT, recheck); }; }, [userKey, fetchTenant]); const value = useMemo(() => ({ ...answer, retry }), [answer, retry]); return {children}; }; /** * The tenant, for anything that renders differently because of it. * * Returns `unknown` rather than throwing when no provider is above it. A missing provider must not be * able to make a sandbox look like production - and it cannot make production look like a sandbox either. */ export const useTenant = (): TenantAnswer => useContext(TenantContext) ?? { kind: 'unknown' };