feat: add browser auth shell
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m0s

This commit is contained in:
ekklesia
2026-08-23 22:01:26 -05:00
parent 91aa9c2930
commit a8c7c50b4d
26 changed files with 2702 additions and 194 deletions
+21 -3
View File
@@ -10,6 +10,8 @@ export interface ModuleUiFrameProps {
readonly config: EkklesiaHostConfig;
readonly descriptor: HostModuleUiDescriptor;
readonly principal: ModuleUiPrincipal;
/** Host-internal session signal. It never enters the module-facing context. */
readonly onAuthorizationResponse?: (status: 401 | 403) => void;
/** Test-only seam, forwarded to the generic loader so a test can hold an import pending and prove
* the stale-load rule. Production composition leaves it unset and the loader's own
* `(url) => import(url)` runs. */
@@ -49,7 +51,7 @@ export interface ModuleUiFrameProps {
export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
const containerRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<FrameStatus>("loading");
const { config, descriptor, importModule, principal } = props;
const { config, descriptor, importModule, onAuthorizationResponse, principal } = props;
useEffect(() => {
const container = containerRef.current;
@@ -60,11 +62,27 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
let mounted: Awaited<ReturnType<typeof loadModuleUi>> | undefined;
setStatus("loading");
const reportAuthorizationResponse = (status: 401 | 403): void => {
// A module may retain an in-flight request while logout or another auth transition unmounts
// this frame. Its eventual response still belongs to that module call, but must not be allowed
// to cancel or overwrite the newer host transition.
if (!cancelled) {
onAuthorizationResponse?.(status);
}
};
void (async () => {
try {
const instance = await loadModuleUi({
container,
context: createModuleUiContext({ config, descriptor, principal }),
context: createModuleUiContext({
config,
descriptor,
principal,
...(onAuthorizationResponse
? { onAuthorizationResponse: reportAuthorizationResponse }
: {}),
}),
...(importModule ? { importModule } : {}),
source: {
moduleId: descriptor.id,
@@ -94,7 +112,7 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
// already in progress, and a module that fails to clean up must not take the shell with it.
void mounted?.unmount().catch(() => undefined);
};
}, [config, descriptor, importModule, principal]);
}, [config, descriptor, importModule, onAuthorizationResponse, principal]);
return (
<section aria-live="polite" style={{ display: "grid", gap: "0.75rem" }}>