const RECOVERY = new Set(["none", "retry-later", "reauthorize", "enable-plan", "manage-usage", "fix-request", "fix-configuration", "review-again", "resolve-handoff"]); export function prepareSiwcBrowser(browserWindow) { let popup; let launched = false; const closeUnused = () => { if (!launched) { try { popup?.close(); } catch { /* The user may already have closed it. */ } } }; // Reserve the tab during the click, before any asynchronous provider/API work. // Sever the opener before navigating; never transfer the private wizard session. try { popup = browserWindow?.open?.("about:blank", "_blank"); if (popup) popup.opener = null; } catch { closeUnused(); popup = null; } return { launchMode: popup ? "same-browser" : "system-browser", closeUnused, launch(result) { if (result?.launchMode === "system-browser") { closeUnused(); return; } if (result?.launchMode !== "same-browser" || !popup || popup.closed || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(result.attemptId)) { throw new Error("The sign-in tab is unavailable. Return to Relmio and try again."); } popup.location.replace(`/__relmio/oauth/launch/${result.attemptId}`); launched = true; }, }; } export function siwcErrorFromResponse(result, status) { const error = new Error(typeof result?.error === "string" ? result.error : "The request did not complete."); error.status = Number.isInteger(result?.status) ? result.status : Number.isInteger(result?.upstreamStatus) ? result.upstreamStatus : status; error.recovery = RECOVERY.has(result?.recovery) ? result.recovery : "none"; for (const key of ["code", "param", "requestId"]) { if (typeof result?.[key] === "string" && result[key].length <= 128) error[key] = result[key]; } if (typeof result?.registrationId === "string" && /^[A-Za-z0-9_-]{8,128}$/u.test(result.registrationId)) error.registrationId = result.registrationId; if (result?.upstream && typeof result.upstream === "object") error.upstream = result.upstream; for (const flag of ["remoteOutcomeUnknown", "retryBlocked"]) { if (result?.[flag] === true) error[flag] = true; } return error; } export function siwcErrorText(error) { const detail = error?.upstream?.body?.detail ?? error?.upstream?.body?.error?.message; const text = typeof detail === "string" && detail.length <= 240 ? detail : error?.message ?? "The request did not complete."; const fields = [error?.code && `Code: ${error.code}`, error?.param && `Field: ${error.param}`, error?.requestId && `Request ID: ${error.requestId}`].filter(Boolean); return fields.length ? `${text} ${fields.join(". ")}.` : text; } const USAGE_URL = "https://chatgpt.com/settings/usage"; export function accountUiState(account) { if (!account) return "new"; if (account.ownership === "handoff-pending") return "handoff-pending"; if (account.ownership === "transferred") return "transferred"; if (account.session === "reauthorize") return "reauthorize"; if (account.session !== "connected") return "signed-out"; if (account.hostReady === false) return "host-unavailable"; if (account.planPermission !== "granted") return "identity-only"; return account.planEnabled ? "plan-active" : "plan-paused"; } export function normalizeSiwcAccount(value) { if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.registrationId !== "string" || !/^[A-Za-z0-9_-]{8,128}$/u.test(value.registrationId) || typeof value.label !== "string" || value.label.length > 160 || (value.email !== undefined && (typeof value.email !== "string" || value.email.length > 254)) || value.identity !== "verified" || !["signed-out", "connected", "reauthorize"].includes(value.session) || !["granted", "not-granted"].includes(value.planPermission) || typeof value.planEnabled !== "boolean" || !["owned", "handoff-pending", "transferred"].includes(value.ownership) || typeof value.generation !== "string" || !/^[A-Za-z0-9_-]{8,128}$/u.test(value.generation) || typeof value.ownerHostId !== "string" || !/^urn:uuid:[a-f0-9-]{36}$/iu.test(value.ownerHostId) || typeof value.ownerRuntimeId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/u.test(value.ownerRuntimeId) || typeof value.needsPlanWelcome !== "boolean" || (value.hostReady !== undefined && typeof value.hostReady !== "boolean")) { throw new Error("The ChatGPT account status is invalid."); } return { registrationId: value.registrationId, label: value.label, ...(value.email !== undefined ? { email: value.email } : {}), identity: "verified", session: value.session, planPermission: value.planPermission, planEnabled: value.planEnabled, ownership: value.ownership, generation: value.generation, ownerHostId: value.ownerHostId, ownerRuntimeId: value.ownerRuntimeId, needsPlanWelcome: value.needsPlanWelcome, ...(value.hostReady !== undefined ? { hostReady: value.hostReady } : {}), }; } export function createSiwcControls({ root, api, onChange, onLogin, onError }) { const field = (name) => root.querySelector(`[data-siwc="${name}"]`); const picker = field("account"); const status = field("status"); const dialog = field("welcome"); let accounts = []; let pendingRegistrations = []; let selectedId = null; let models = []; let catalogGeneration = null; let loadVersion = 0; let loaded = false; let busy = false; let previewMode = false; let notice = ""; let previewFixture = null; let welcomeDue = false; let welcomeTrigger = null; function selected() { return accounts.find((account) => account.registrationId === selectedId) ?? null; } function display(account) { return `${account.label}${account.email ? ` (${account.email})` : ""} · ${account.registrationId.slice(-8)}`; } function welcomePending() { const account = selected(); return accountUiState(account) === "plan-active" && account.needsPlanWelcome === true; } function usageLimited() { return previewFixture === "usage-limit" && ["plan-active", "plan-paused"].includes(accountUiState(selected())); } function usable(control) { return Boolean(control) && root.contains(control) && !control.disabled && control.checkVisibility?.() !== false; } function render() { const account = selected(); const mode = accountUiState(account); const limited = usageLimited(); const welcome = welcomePending(); // Help receives bounded states only, never account labels or credentials. if (root.dataset) Object.assign(root.dataset, { guideAccountState: mode, guideBusy: String(busy), guidePreview: String(previewMode), guidePlanNotice: String(welcome), guideUsageLimited: String(limited), guidePending: String(pendingRegistrations.length > 0), }); picker.replaceChildren(); if (!accounts.length) { picker.add(new Option("No saved ChatGPT accounts", "")); } else { picker.add(new Option("Choose an account", "")); for (const item of accounts) { const option = new Option(display(item), item.registrationId); option.disabled = item.ownership !== "owned" || item.hostReady === false; picker.add(option); } } picker.value = selectedId ?? ""; picker.disabled = busy || previewMode || accounts.length === 0; const pending = field("pending"); pending.replaceChildren(); for (const item of pendingRegistrations) pending.add(new Option( `${item.label} · ${item.registrationId.slice(-8)}`, item.registrationId, )); field("pending-row").hidden = pendingRegistrations.length === 0; pending.disabled = busy || previewMode; field("finish").disabled = busy || previewMode; // The status and owner lines describe the selected account; this list names the others. const transferred = field("transferred"); const inactiveOwners = accounts.filter((item) => item.ownership !== "owned" && item.registrationId !== selectedId); transferred.replaceChildren(...inactiveOwners.map((item) => { const listItem = document.createElement("li"); listItem.textContent = `${display(item)}: ${item.ownership === "transferred" ? `managed by ${item.ownerRuntimeId}` : "transfer pending, plan requests frozen"}.`; return listItem; })); transferred.hidden = inactiveOwners.length === 0; status.textContent = [ limited ? "ChatGPT usage limit reached. Plan requests are paused. Manage usage in ChatGPT; Relmio does not switch accounts or billing." : welcome ? "ChatGPT plan use is granted. Review the plan notice before using it." : { new: "Continue with ChatGPT to connect an account. A fresh Relmio sign-in is required; old Codex credentials are not imported.", "signed-out": "Signed out. The account label remains here; sign in again to reconnect.", reauthorize: "This account needs a fresh ChatGPT sign-in before it can be used.", "identity-only": "Connected for identity. ChatGPT plan use has not been granted.", "plan-paused": "Connected. ChatGPT plan permission is granted, but plan use is paused in Relmio.", "plan-active": "Model access and each request still depend on this account and its limits.", "handoff-pending": "Transfer is unresolved. Plan requests are frozen until ownership is checked at the destination.", transferred: "This account was transferred to its installation. Manage its plan and sign-out from the installed target.", "host-unavailable": "This registration belongs to another host or runtime. Start a fresh Relmio sign-in here.", }[mode], previewFixture === "staged" && "Staged installation: build interrupted. Review a resume before another write.", notice, ].filter(Boolean).join(" "); field("return").hidden = !account || mode === "transferred" || mode === "handoff-pending"; field("grant").hidden = !["identity-only"].includes(mode); field("resume").hidden = mode !== "plan-paused"; field("pause").hidden = mode !== "plan-active" || limited; field("logout").hidden = !["identity-only", "plan-paused", "plan-active", "reauthorize"].includes(mode); for (const name of ["new", "return", "grant", "resume", "pause", "logout"]) { field(name).disabled = busy || previewMode; } field("welcome-open").hidden = !welcome || dialog.open; field("welcome-open").disabled = busy; field("plan-badge").hidden = mode !== "plan-active" || welcome || limited; const usage = field("usage"); usage.href = USAGE_URL; usage.hidden = !["plan-active", "plan-paused"].includes(mode); // A usage limit makes Manage usage the primary recovery. usage.className = limited ? "rm-button rm-button--primary" : "rm-link rm-link--standalone"; field("owner").textContent = mode === "transferred" || mode === "handoff-pending" ? `Owner: ${account.ownerRuntimeId ?? "destination not confirmed"}` : ""; } // A modal opened in a hidden view is an invisible dialog that makes the page inert, and an // operation lock disables its buttons, so a due welcome waits until both clear. function showWelcome(trigger = null) { welcomeDue = welcomePending() && !dialog.open; if (!welcomeDue || !root.checkVisibility?.() || document.body?.dataset.operationBusy === "true") return; welcomeDue = false; welcomeTrigger = trigger; dialog.showModal(); render(); field("welcome-accept").focus(); } // Operation locks restore the control states captured before the operation started, which // undoes render() for loads that ran inside it. Draw again once the page lock lifts. if (typeof MutationObserver !== "undefined" && document.body) { new MutationObserver(() => { if (!loaded || document.body.dataset.operationBusy !== "false") return; render(); if (welcomeDue) showWelcome(); }).observe(document.body, { attributes: true, attributeFilter: ["data-operation-busy"] }); } async function load({ welcome = true } = {}) { const version = ++loadVersion; const result = await api("/api/siwc/accounts"); if (version !== loadVersion) return selected(); if (!Array.isArray(result.accounts) || !Array.isArray(result.pendingRegistrations)) { throw new Error("The ChatGPT account list could not be verified."); } const previous = selected(); accounts = result.accounts.map(normalizeSiwcAccount); pendingRegistrations = result.pendingRegistrations.map((item) => { if (!item || typeof item.registrationId !== "string" || !/^[A-Za-z0-9_-]{8,128}$/u.test(item.registrationId) || typeof item.label !== "string" || item.label.length > 160) { throw new Error("The unfinished ChatGPT sign-in could not be verified."); } return { registrationId: item.registrationId, label: item.label }; }); previewMode = result.previewMode === true; previewFixture = result.previewFixture ?? null; selectedId = accounts.some((account) => account.registrationId === result.selectedRegistrationId) ? result.selectedRegistrationId : null; loaded = true; if (previous?.registrationId !== selectedId || previous?.generation !== selected()?.generation) { models = []; catalogGeneration = null; onChange?.(selected()); } render(); if (welcome) showWelcome(); else welcomeDue = false; return selected(); } async function change(action) { if (busy) return; const trigger = document.activeElement; busy = true; render(); try { await action(); await load(); } catch (error) { onError?.(error); await load({ welcome: false }).catch(onError); } finally { busy = false; render(); // The trigger may now be hidden (Sign out, Pause) or was disabled while busy. const active = document.activeElement; if (!active || active === document.body || (root.contains(active) && !usable(active))) { (usable(trigger) ? trigger : field("state")).focus(); } } } picker.addEventListener("change", () => change(async () => { if (!accounts.some((account) => account.registrationId === picker.value && account.ownership === "owned" && account.hostReady !== false)) { throw new Error("Choose an account owned by this computer."); } await api("/api/siwc/select", { method: "POST", body: { registrationId: picker.value } }); })); field("new").addEventListener("click", () => onLogin({ purpose: "sign-in" })); field("finish").addEventListener("click", () => { const registrationId = field("pending").value; if (pendingRegistrations.some((item) => item.registrationId === registrationId)) { onLogin({ purpose: "sign-in", registrationId }); } }); field("return").addEventListener("click", () => { const account = selected(); if (account) onLogin({ purpose: "sign-in", registrationId: account.registrationId }); }); field("grant").addEventListener("click", () => { const account = selected(); if (account) onLogin({ purpose: "enable-plan", registrationId: account.registrationId }); }); field("resume").addEventListener("click", () => change(async () => { const account = selected(); await api("/api/siwc/plan", { method: "POST", body: { registrationId: account.registrationId, enabled: true, expectedGeneration: account.generation, } }); })); field("pause").addEventListener("click", () => change(async () => { const account = selected(); await api("/api/siwc/plan", { method: "POST", body: { registrationId: account.registrationId, enabled: false, expectedGeneration: account.generation, } }); })); field("logout").addEventListener("click", () => change(async () => { const account = selected(); const result = await api("/api/siwc/logout", { method: "POST", body: { registrationId: account.registrationId, expectedGeneration: account.generation, } }); notice = result.revocation === "unconfirmed" ? "Local tokens cleared. Remote revocation was not confirmed. Disconnect Relmio in ChatGPT settings." : "Provider revocation finished where applicable. Old Codex data is separate."; })); field("welcome-open").addEventListener("click", (event) => showWelcome(event.currentTarget)); field("welcome-accept").addEventListener("click", () => change(async () => { const account = selected(); await api("/api/siwc/ack", { method: "POST", body: { registrationId: account.registrationId, expectedGeneration: account.generation, } }); dialog.close(); })); // Native dialog traversal can leave the final control for browser chrome. // Wrap only the modal's boundaries; preserve native Escape and interior traversal. dialog.addEventListener("keydown", (event) => { if (!dialog.open || event.key !== "Tab") return; const focusable = [...dialog.querySelectorAll("a[href], button, input, select, textarea, [tabindex]")] .filter((control) => control.tabIndex >= 0 && !control.disabled && control.checkVisibility?.() !== false); const first = focusable[0]; const last = focusable.at(-1); if (!first || !(event.shiftKey ? document.activeElement === first : document.activeElement === last)) return; event.preventDefault(); (event.shiftKey ? last : first).focus(); }); // Escape closes without an acknowledgment: the plan stays unconfirmed and Review plan notice // reopens it. Focus returns to the control that opened it, or to the account state. dialog.addEventListener("close", () => { render(); (usable(welcomeTrigger) ? welcomeTrigger : field("state")).focus(); welcomeTrigger = null; }); return { load, selected, showWelcome, isPreview: () => previewMode, isUsageLimited: usageLimited, async authorized({ purpose } = {}) { const account = await load({ welcome: false }); if (purpose === "enable-plan" && account?.planPermission === "granted" && !account.planEnabled) { await api("/api/siwc/plan", { method: "POST", body: { registrationId: account.registrationId, enabled: true, expectedGeneration: account.generation, } }); } return load(); }, async catalog() { const account = selected(); if (accountUiState(account) !== "plan-active" || account.needsPlanWelcome) { models = []; catalogGeneration = null; return []; } const id = account.registrationId; const result = await api("/api/siwc/models"); if (selected()?.registrationId !== id || result.account?.registrationId !== id || !Array.isArray(result.models)) throw new Error("The ChatGPT account changed during model discovery."); models = result.models; catalogGeneration = result.account.generation; return [...models]; }, catalogIsCurrent() { return selected()?.generation === catalogGeneration; }, clear() { ++loadVersion; models = []; catalogGeneration = null; }, }; } export function createSiwcRecovery({ root, api, vps = false, getTarget, onReview, onResult, onError }) { const node = (tag, text, className) => { const item = document.createElement(tag); if (text) item.textContent = text; if (className) item.className = className; return item; }; const details = node("details", "", "rm-disclosure"); details.append(node("summary", "Recover a transfer or staged installation")); const body = node("div", "", "rm-disclosure__body"); const label = node("label", "Recovery account", "rm-field"); const account = node("select", "", "rm-select"); label.append(account); const targetLabel = node("label", "Installation", "rm-field"); const target = node("select", "", "rm-select"); for (const [value, text] of [["codex-chatgpt", "Codex App Server"], ["codex-chat", "Codex Chat Adapter"], ["n8n-openai-oauth", "n8n ChatGPT sidecar"]]) { const option = node("option", text); option.value = value; target.append(option); } targetLabel.append(target); targetLabel.hidden = vps; const status = node("p", "Review is read-only. Confirm separately before recovery writes.", "rm-small"); status.setAttribute("role", "status"); status.setAttribute("aria-live", "polite"); status.tabIndex = -1; const check = node("button", "Review pending transfer", "rm-button rm-button--sm"); const resume = node("button", "Review staged resume", "rm-button rm-button--sm"); const confirmLabel = node("label", "", "rm-check"); const confirm = node("input"); confirm.type = "checkbox"; confirmLabel.append(confirm, node("span", "I approve checking this reviewed destination and finishing its transfer acknowledgment. No credential export is repeated.")); confirmLabel.hidden = true; const apply = node("button", "Reconcile reviewed transfer", "rm-button rm-button--primary"); for (const button of [check, resume, apply]) button.type = "button"; apply.hidden = true; apply.disabled = true; body.append(label, targetLabel, status, check, resume, confirmLabel, apply); details.append(body); root.append(details); let reviewId = null; let busy = false; let preview = false; function clear() { reviewId = null; confirm.checked = false; confirmLabel.hidden = true; apply.hidden = true; apply.disabled = true; } async function load({ target: selectedTarget, registrationId } = {}) { clear(); const result = await api("/api/siwc/accounts"); preview = result.previewMode === true; account.replaceChildren(); for (const value of result.accounts.map(normalizeSiwcAccount)) { const ownership = { owned: "owned here", "handoff-pending": "transfer pending", transferred: "transferred" }[value.ownership]; const option = node("option", `${value.label} · ${value.registrationId.slice(-8)} (${ownership})`); option.value = value.registrationId; account.append(option); } if (selectedTarget) target.value = selectedTarget; if (registrationId) account.value = registrationId; check.disabled = resume.disabled = preview || !account.value; if (result.previewFixture === "staged") status.textContent = "Staged installation. Files remain owned; resume needs a fresh read-only review and final approval."; if (result.previewFixture === "handoff-pending") status.textContent = "Transfer acknowledgment is pending. Requests stay frozen; reconciliation reads the destination's original receipt."; } details.addEventListener("toggle", () => { if (details.open) void load().catch(onError); }); account.addEventListener("change", clear); target.addEventListener("change", clear); confirm.addEventListener("change", () => { apply.disabled = !confirm.checked || !reviewId || busy; }); async function perform(action) { if (busy || preview) return; busy = true; check.disabled = resume.disabled = apply.disabled = true; try { if (action !== "apply") { clear(); const result = await api(`${vps ? "/api/siwc/vps" : "/api/local/siwc"}/recovery/review`, { method: "POST", body: { ...(vps ? getTarget() : { target: target.value, ...getTarget?.({ target: target.value, action }) }), registrationId: account.value, action }, }); if (action === "resume") { onReview(result); } else { if (typeof result.reviewId !== "string") throw new Error("Transfer review was not confirmed."); reviewId = result.reviewId; const ids = result.destination; status.textContent = `Reviewed ${result.account.label} at ${vps ? `n8n ${ids.n8nContainerId.slice(0, 12)}, network ${ids.networkId.slice(0, 12)}` : target.options[target.selectedIndex].textContent}. Confirm to reconcile its acknowledgment.`; confirmLabel.hidden = apply.hidden = false; confirm.focus(); } } else { if (!confirm.checked || !reviewId) throw new Error("Review and confirm this transfer first."); const id = reviewId; clear(); const result = await api(`${vps ? "/api/siwc/vps" : "/api/local/siwc"}/recovery/reconcile`, { method: "POST", body: { reviewId: id, confirmed: true }, }); status.textContent = result.outcome === "finished" ? "Original receipt confirmed. The destination owns this registration. Review its staged resume if installation is incomplete." : "Destination did not accept this transfer. The old session stays frozen. Start a fresh sign-in before reviewing recovery."; status.focus(); await onResult?.(result); } } catch (error) { clear(); onError(error); } finally { busy = false; check.disabled = resume.disabled = preview || !account.value; apply.disabled = !confirm.checked || !reviewId; } } check.addEventListener("click", () => { void perform("reconcile"); }); resume.addEventListener("click", () => { void perform("resume"); }); apply.addEventListener("click", () => { void perform("apply"); }); return { clear, load }; }