import { sameRelease } from "../installation-metadata.ts"; import { useEffect, useRef, useState } from "octane"; import { useMutation } from "@octanejs/tanstack-query"; import { usePublisherSession } from "./PublisherQueryProvider"; import { useInstallationsQuery, installationsOptions, } from "./queries/use-installations-query"; import { useInstallationQuery, installationOptions, } from "./queries/use-installation-query"; import type { usePublisherIdentityQuery } from "./queries/use-publisher-identity-query"; import { PublisherReadError } from "./publisher-request"; import type { Installation, ReleaseIdentity, } from "../installation-metadata.ts"; export const installationMessages = { recovery_required: "Installation was interrupted. Recover installation checks the existing resources before continuing.", artifact_unavailable: "This pre-release target is no longer available. Contact the publisher for manual recovery. Your existing resources and data have not been reset.", setup_required: "The publisher needs to finish installation setup. Refresh after the setup has been corrected.", reauthorization_required: "Reconnect Cloudflare and select this installation’s account to continue. Your existing installation is retained.", account_denied: "Select the account shown for this installation. Reconnect Cloudflare if it is not available.", resource_conflict: "An existing Cloudflare resource does not match this installation. It has been preserved. Ask your account administrator to resolve the conflict before retrying.", deployment_failed: "Flarebot could not finish deploying. Retry this installation to continue with its existing resources.", health_failed: "Flarebot did not pass its readiness checks. Retry this installation to check and repair it.", temporarily_unavailable: "Flarebot could not be reached. Check your connection and try again. Your saved request and last available status are retained.", installation_conflict: "This installation changed. Refresh its status before continuing.", installation_not_found: "This installation is no longer available to your signed-in account.", invalid_metadata: "This installation request could not be verified. Refresh the page and try again.", forbidden: "This request could not be verified. Reload Flarebot and try again.", } as const; type Failure = keyof typeof installationMessages; type Intent = { ownerSubject: string; accountId: string; installationId: string | null; action: "reserve" | "start" | "recover" | "upgrade"; requestId: string; target?: ReleaseIdentity | null; }; interface CommandState { selectedId: string | null; cursor: string | null; busy: boolean; error: Failure | null; pending: Intent | null; } const initial: CommandState = { selectedId: null, cursor: null, busy: false, error: null, pending: null, }; const storageKey = "flarebot-installation-intent"; const id = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{32}$/.test(value); const safeError = (value: unknown): Failure => typeof value === "string" && Object.hasOwn(installationMessages, value) ? (value as Failure) : "temporarily_unavailable"; function saved() { try { const value = JSON.parse(sessionStorage.getItem(storageKey) ?? "null"); if ( !value || typeof value.ownerSubject !== "string" || !(value.selectedId === null || id(value.selectedId)) ) return null; const p = value.pending; if ( p && (p.ownerSubject !== value.ownerSubject || !id(p.accountId) || !id(p.requestId) || !["reserve", "start", "recover", "upgrade"].includes(p.action) || (p.action === "upgrade" && (!p.target || typeof p.target.version !== "string" || !/^[a-f0-9]{64}$/.test(p.target.artifactDigest) || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test( p.target.sourceRevision, ))) || (p.action === "reserve" ? p.installationId !== null : !id(p.installationId))) ) return null; return value as { ownerSubject: string; selectedId: string | null; pending: Intent | null; }; } catch { return null; } } // Browser intent is nonsecret and never grants authority. Every POST still // resolves its owner and selected account from the server's authenticated session. export function useInstallationStatus( identity: ReturnType, ) { const cache = usePublisherSession(); const owner = cache.getSnapshot(); const [state, setState] = useState(() => { const restored = typeof window !== "undefined" ? saved() : null; return restored?.ownerSubject === owner.ownerSubject ? { ...initial, selectedId: restored.selectedId, pending: restored.pending, } : initial; }); const commands = useRef(state); const generation = useRef(0); const controller = useRef(null); const alive = useRef(false); const publish = (changes: Partial) => { commands.current = { ...commands.current, ...changes }; if (alive.current) setState(commands.current); }; const pagination = useMutation({ mutationFn: async (cursor: string | null) => { const version = generation.current; await cache.client.fetchQuery({ ...installationsOptions(cache, cursor), staleTime: 0, }); if ( alive.current && version === generation.current && cache.getSnapshot().epoch === owner.epoch ) publish({ cursor }); }, }); const listKey = installationsOptions(cache, state.cursor).queryKey; const cached = cache.client.getQueryData(listKey); const cachedSelected = cached?.installations.find( (item) => item.installationId === state.selectedId, ) ?? cache.client.getQueryData( installationOptions(cache, state.selectedId ?? "").queryKey, )?.installation ?? cached?.installations[0]; const list = useInstallationsQuery( state.cursor, state.busy || pagination.isPending, ["installing", "updating"].includes(cachedSelected?.status ?? ""), ); const wanted = state.selectedId ?? list.data?.installations[0]?.installationId ?? ""; const listed = list.data?.installations.find( (item) => item.installationId === wanted, ); const detail = useInstallationQuery({ id: wanted, enabled: !state.busy && !!wanted && !listed, interval: ["installing", "updating"].includes(cachedSelected?.status ?? "") ? 2000 : 30_000, }); function snapshot() { const command = commands.current; const page = cache.client.getQueryData( installationsOptions(cache, command.cursor).queryKey, ); const selectedId = command.selectedId ?? page?.installations[0]?.installationId; const selected = page?.installations.find((item) => item.installationId === selectedId) ?? cache.client.getQueryData( installationOptions(cache, selectedId ?? "").queryKey, )?.installation ?? null; return { ...command, ownerSubject: cache.getSnapshot().ownerSubject, selected, latestRelease: page?.latestRelease ?? null, }; } const confirm = (installation: Installation) => { void cache.client.cancelQueries({ queryKey: cache.key("installations") }); void cache.client.cancelQueries({ queryKey: cache.key("installation", installation.installationId), }); cache.client.setQueryData( installationsOptions(cache, commands.current.cursor).queryKey, (page) => page ? { ...page, installations: [ installation, ...page.installations.filter( (item) => item.installationId !== installation.installationId, ), ], } : page, ); cache.client.setQueryData( installationOptions(cache, installation.installationId).queryKey, { installation, latestRelease: snapshot().latestRelease, upgradeFrom: list.data?.upgradeFrom ?? [], }, ); publish({ selectedId: installation.installationId }); }; const persist = () => { const s = snapshot(); try { if (s.ownerSubject) sessionStorage.setItem( storageKey, JSON.stringify({ ownerSubject: s.ownerSubject, selectedId: s.selectedId ?? s.selected?.installationId ?? null, pending: s.pending, }), ); } catch { /* Native request replay remains valid if browser storage is unavailable. */ } }; const cancel = () => { generation.current++; controller.current?.abort(); }; const request = async ( path: string, options: RequestInit = {}, timeoutMs = 15_000, ) => { const response = await fetch(path, { credentials: "same-origin", cache: "no-store", ...options, signal: AbortSignal.any([ controller.current!.signal, AbortSignal.timeout(timeoutMs), ]), }); const data = await response.json(); return { response, data }; }; async function refresh( cursor = commands.current.cursor, selectedId?: string, ) { if (commands.current.busy || pagination.isPending) return; pagination.reset(); publish({ ...(selectedId ? { selectedId } : {}), error: null }); if (!cache.getSnapshot().ownerSubject) { await identity.refetch(); return; } if (cursor !== commands.current.cursor) { pagination.mutate(cursor); return; } await Promise.all([ list.refetch(), ...(!listed && wanted ? [detail.refetch()] : []), ]); } const choose = (record: Installation) => { if (commands.current.busy || pagination.isPending) return; publish({ selectedId: record.installationId, error: null }); persist(); }; async function run( accountId: string, action: Intent["action"], record: Pick | null = null, ) { const s = snapshot(); const epoch = cache.getSnapshot().epoch; if ( !navigator.onLine || pagination.isPending || s.busy || !s.ownerSubject || (record && record.accountId !== accountId) ) return; const pending = s.pending; if ( pending && (pending.ownerSubject !== s.ownerSubject || pending.accountId !== accountId) ) { publish({ error: "account_denied" }); return; } if ( pending && (pending.action !== action || pending.installationId !== (record?.installationId ?? null)) ) { publish({ error: "installation_conflict" }); return; } cancel(); const version = generation.current; controller.current = new AbortController(); let intent: Intent = pending ?? { ownerSubject: s.ownerSubject, accountId, installationId: record?.installationId ?? null, action, target: action === "upgrade" ? s.selected?.status === "failed" ? s.selected.desiredRelease : s.latestRelease : null, requestId: crypto.randomUUID().replaceAll("-", ""), }; publish({ busy: true, error: null, pending: intent }); await cache.client.cancelQueries({ queryKey: cache.key() }); if (version !== generation.current || cache.getSnapshot().epoch !== epoch) return; persist(); let reconcile = false; try { while (true) { const path = intent.action === "reserve" ? "/api/installations" : `/api/installations/${intent.installationId}/${intent.action}`; // Upgrade/recovery can verify deployed code with Cloudflare before // acknowledging the command. Keep polling quick without aborting that // valid preflight at the status-read deadline. Explicit cancellation and // the frozen replay intent still apply if this longer deadline expires. const { response, data } = await request( path, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ requestId: intent.requestId, ...(intent.action === "upgrade" ? { target: JSON.stringify(intent.target) } : {}), }).toString(), }, 120_000, ); if ( version !== generation.current || cache.getSnapshot().epoch !== epoch ) return; if (!response.ok) { const code = safeError(data.error); reconcile = [ "installation_conflict", "installation_not_found", "invalid_metadata", "recovery_required", ].includes(code); throw code; } const installation: Installation = data.installation; if (installation.ownerSubject !== intent.ownerSubject) { // A successful response confirms an identity change in another tab. // Clear the previous owner's view before the next identity-only read. try { sessionStorage.removeItem(storageKey); } catch { /* Storage may be disabled. */ } cache.identify(installation.ownerSubject); return; } if (installation.accountId !== intent.accountId) { // A different tab may have changed the server's selected account. // Reservation is confirmed but deployment has not been authorized here. confirm(installation); publish({ pending: null }); persist(); throw "account_denied"; } confirm(installation); if (intent.action !== "reserve" || installation.status !== "reserved") { publish({ pending: null }); persist(); break; } intent = { ...intent, installationId: installation.installationId, action: "start", requestId: crypto.randomUUID().replaceAll("-", ""), }; publish({ pending: intent }); persist(); } } catch (error) { if ( version === generation.current && cache.getSnapshot().epoch === epoch ) { const code = safeError(error); publish({ error: code, ...(reconcile ? { pending: null } : {}) }); if (reconcile) persist(); } } finally { if ( version === generation.current && cache.getSnapshot().epoch === epoch ) { publish({ busy: false }); if (reconcile) void refresh(); } } } const forget = () => { // Keep the form mounted until its native disconnect POST navigates away. cancel(); cache.clearIntents(); }; async function continuePending(accountId: string) { const pending = snapshot().pending; if (!pending || snapshot().busy) return; // Replay the frozen target, independently of the currently viewed page. // Its owner/account are still verified by the production POST route. await run( accountId, pending.action, pending.installationId ? { installationId: pending.installationId, accountId: pending.accountId, } : null, ); } async function recover(accountId: string, record: Installation) { const s = snapshot(); if ( s.busy || !["installing", "updating"].includes(record.status) || record.ownerSubject !== s.ownerSubject || accountId !== record.accountId || (s.pending && (s.pending.installationId !== record.installationId || s.pending.ownerSubject !== s.ownerSubject)) ) return; // This is an explicit owner recovery action, never a polling side effect. // The server terminates/reconciles the old attempt before starting a new one. publish({ pending: null, error: null }); persist(); await run(accountId, "recover", record); } useEffect(() => { alive.current = true; return () => { alive.current = false; cancel(); }; }, []); const selected = listed ?? detail.data?.installation ?? null; useEffect(() => { const pending = commands.current.pending; if ( !listed && detail.error instanceof PublisherReadError && detail.error.status === 404 && list.data?.installations[0] ) publish({ selectedId: list.data.installations[0].installationId }); if ( pending && pending.installationId === selected?.installationId && selected.status === "ready" && (pending.action !== "upgrade" || sameRelease(pending.target ?? null, selected.installedRelease)) ) publish({ pending: null }); persist(); }, [selected, state.pending, detail.error, list.data]); const readError = pagination.error ?? list.error ?? (!listed ? detail.error : null) ?? identity.error; return { ...state, ownerSubject: owner.ownerSubject, signedOut: owner.signedOut, installations: list.data?.installations ?? [], latestRelease: list.data?.latestRelease ?? null, upgradeFrom: list.data?.upgradeFrom ?? [], selected, nextCursor: list.data?.nextCursor ?? null, loading: pagination.isPending || identity.isFetching || list.isFetching || (!listed && detail.isFetching), refreshedAt: list.dataUpdatedAt || null, error: state.error ?? (readError && !( readError instanceof PublisherReadError && [401, 404].includes(readError.status) ) ? safeError( readError instanceof PublisherReadError ? readError.code : null, ) : null), refresh, choose, run, continuePending, recover, forget, }; }