import { useState, useCallback, useEffect, useMemo, useRef, createContext, useContext, ReactNode } from 'react'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; import { useAuthServiceContext } from './AuthService.tsx'; // / finding B30 - THE ONE CLIENT-SIDE ANSWER TO "MAY I CALL THIS?". // // THE DEFECT THIS EXISTS FOR. Components gated their mounts on `currentUser.fhir.resourceType` - // which is IDENTITY, not capability - so an HR user, a marketing user and a practitioner all read as // `Practitioner` and all mounted practitioner-only surfaces. The design-pass walk measured the result: // five roles firing requests their own token is explicitly denied, on ordinary page loads. // Admin is what made it decisive. Admin is the LEAST restricted role in the product, so this was // never restricted roles overreaching - it is components mounting without consulting capability at all. // // THE BASE IS `apiUrl`, NOT `fhirBaseApi`. `GET /R4/my-capabilities` is declared in the api service's // own `templates/api.yaml` - the same gateway as /LOINC, /UCUM and the akashic-agreements family - while // `/my-visit-invitations` (the first route this gates) lives on `fhir-base`. Six hosts, and the wrong one // 404s silently with nothing to type-check it. `apiUrl` already ends // in `/R4`, so the path is `${API_URL}/my-capabilities`, never `${API_URL}/R4/my-capabilities`. // // THE ANSWER IS ROUTE AUTHORITY, NOT "THIS WILL SUCCEED". The server evaluates ONE of the // authorizer's seven gates: `allowedRoles` / `requiredPermission` / self-Practitioner / system-client / // collaborator-read. A permitted route can still be refused by SMART scopes, per-patient access, the // org's subscription, the adolescent gate, the org-id header or the BAA. The response says so itself // (`question: 'route-authority'`). Never present this as "the request will work". const API_URL = environment.apiUrl; export interface CapabilityResponse { question: string; organizationId: string; roles: string[]; permitted: string[]; conditionalOnResource: string[]; } /** * `ready` - we have an answer. `unavailable` - we asked and could not get one. * * KEEPING THESE TWO APART IS THE WHOLE SAFETY PROPERTY, and collapsing them is the way this feature * turns into an outage. "The server says you may not" must gate a mount. "We could not reach the server" * must NOT - a client that fails closed on unavailability blanks every gated surface for every user the * moment the endpoint is slow, down, or simply not deployed yet. That last case is not hypothetical: this * client and the route ship in different repos, so between the two merges the endpoint returns 404 for * everyone the card-237 coordinated-deploy shape, avoided here by construction rather than by ordering. */ export type CapabilityState = 'loading' | 'ready' | 'unavailable'; export const useCapabilityService = () => { const { get } = useHttpService(); const { currentUser, joinRecovery, currentOrganization } = useAuthServiceContext(); const [permitted, setPermitted] = useState>(new Set()); const [conditional, setConditional] = useState>(new Set()); const [state, setState] = useState('loading'); // The practice the answer is for. A switch re-reads, and an answer for another practice is never this one's. const [answeredFor, setAnsweredFor] = useState(); // Gated on `currentUser` - an unauthenticated fetch here would 401 and, on a token refresh, feed the // provider-fetch/login loop this codebase has already been bitten by //. // `fhir.id` is the identity every caller class carries (Patient, Practitioner, RelatedPerson), and it // is what changes when the signed-in human changes. `cognito` is the second source only because a // session can be authenticated a moment before its FHIR resource has resolved - asking then would ask // as the wrong person, so both must be absent for us to hold off. const userKey = currentUser?.fhir?.id ?? currentUser?.cognito?.sub ?? null; const organizationKey = currentOrganization?.id; // A practice can change what this person may do while their page stays open, so coming back to the page asks // again. Only on that return, never on a timer. const [askedAgain, setAskedAgain] = useState(0); useEffect(() => { const returned = () => { if (document.visibilityState === 'visible') setAskedAgain((asked) => asked + 1); }; const restored = (event: PageTransitionEvent) => { if (event.persisted) setAskedAgain((asked) => asked + 1); }; document.addEventListener('visibilitychange', returned); window.addEventListener('pageshow', restored); return () => { document.removeEventListener('visibilitychange', returned); window.removeEventListener('pageshow', restored); }; }, []); // Whose answer is held. Asking again for the same person and practice keeps it shown until the new one lands. const answeredOwner = useRef(null); useEffect(() => { const owner = userKey ? userKey + '|' + (organizationKey ?? '') : null; const again = owner !== null && answeredOwner.current === owner; if (!again) { answeredOwner.current = null; setAnsweredFor(undefined); setState('loading'); setPermitted(new Set()); setConditional(new Set()); } if (!userKey) return; let cancelled = false; const load = async () => { try { const response = await get(`${API_URL}/my-capabilities`); if (cancelled) return; setPermitted(new Set(response?.permitted ?? [])); setConditional(new Set(response?.conditionalOnResource ?? [])); setAnsweredFor(response?.organizationId); setState('ready'); answeredOwner.current = owner; } catch { // A failed second asking leaves the answer already held; only a first asking reports unavailable. if (cancelled || again) return; // Deliberately NOT surfaced to the user and deliberately NOT retried in a loop. There is // nothing here a human can act on, and every consumer falls back to the behaviour it had // before this service existed - so the failure mode is "the product works as it did // yesterday", not "a broken screen". setPermitted(new Set()); setConditional(new Set()); setState('unavailable'); } }; void load(); return () => { cancelled = true; }; // Re-reads when the signed-in human or the practice changes, reporting nothing known in between. // And once more when a just-joined member's Check again confirms their access: the answer read while it // settled can be missing the role it was waiting on. }, [userKey, organizationKey, get, joinRecovery, askedAgain]); /** * May the caller call this route? `undefined` means NOT KNOWN - never "no". * * Callers combine this with their own previous rule using `??`, and that is deliberate rather than * an instance of the `??`-fallback family that has produced five defects on this codebase (`?? 100` * appointments, `?? 2` seats, `?? 1` questionnaires...). Those substituted an INVENTED VALUE for a * missing fact. This substitutes the rule the component used before the signal existed, which is the * only honest answer when capability is unknown - and it is why an outage degrades to yesterday's * behaviour instead of to a blank screen. * * `path` is written the way the client writes it (`/my-visit-invitations`); the `/R4` prefix the * route table keys on is added here, in one place. * * Exact route keys only. The route table's wildcard entries (`GET /R4/Practitioner/*`) are not * pattern-matched here - a by-id route whose authority depends on WHICH id is addressed is reported * by the server as `conditionalOnResource` instead, because this endpoint is not told the id. */ const mayCall = useCallback((method: string, path: string): boolean | undefined => { if (state !== 'ready') return undefined; return permitted.has(`${method.toUpperCase()} /R4${path}`); }, [state, permitted]); /** Does this route's authority depend on which resource is addressed (the self-Practitioner case)? */ const isConditionalOnResource = useCallback((method: string, path: string): boolean => conditional.has(`${method.toUpperCase()} /R4${path}`), [conditional]); return useMemo( () => ({ state, mayCall, isConditionalOnResource, organizationId: answeredFor }), [state, mayCall, isConditionalOnResource, answeredFor], ); }; const CapabilityServiceContext = createContext | undefined>(undefined); export const CapabilityServiceProvider = ({ children }: { children: ReactNode }) => { const capabilityService = useCapabilityService(); return ( {children} ); }; /** * Returns a NEUTRAL service when no provider is above it, rather than throwing. * * Every other service in this repo throws here, and that is right for them: a chart page without its * data service is broken and should say so loudly. This one is different in kind - it is an OPTIONAL * refinement of gates that already work. A component that throws because a capability provider is * missing would take down a working surface in order to improve a mount decision, which is the same * mistake caught when the scribe accessor nearly took down note writing for an optional paid * feature. Unmounted `state: 'unavailable'` every consumer keeps its previous rule. */ export const useCapabilityServiceContext = () => { const context = useContext(CapabilityServiceContext); if (context === undefined) { return NEUTRAL_CAPABILITY_SERVICE; } return context; }; const NEUTRAL_CAPABILITY_SERVICE = { state: 'unavailable' as CapabilityState, mayCall: () => undefined, isConditionalOnResource: () => false, organizationId: undefined as string | undefined, };