import { createManagedDesktopStore, retryDelay, unreadableRecord } from "./managed-desktop.mjs"; import { CLOUD_MACHINE_CONNECTABLE, cloudUnreachableText, parseCloudCredit, parseCloudLanding, parseCloudOffer, parseCloudPurchase, parseCloudSummary, parseCloudTeams, parseCloudRoom, parseCloudTrial, parseCloudUsage, parsePairingGrant, parsePublicOffer, withoutTrial } from "./cloud-home.mjs"; import { CLOUD_ID, CLOUD_SWATCHES, cleanCloudListName, ORGANIZATION_BLOCKS, otherOwnClouds, ownCloud, parseCloudList, parseCloudRenewal, parseOrganizations, releaseVersion } from "./cloud-list.mjs"; import { planTeam, teamPlan } from "./cloud-team-plan.mjs"; import { cleanCode, cleanEmail, parseSignInStart, SIGN_IN_START, SIGN_IN_TRIES, SIGN_IN_VERIFY, signInIdentity, startFailure, verifyFailure } from "./cloud-signin.mjs"; import nativeText from "./native-text.cjs"; const { englishText } = nativeText; export const CLOUD_ORIGIN = "https://cloud.openmausbot.com"; /** The staging Admin (Dodo test mode, real test Clouds): a fixed address, on only with OMB_CLOUD_STAGING=1. */ export const STAGING_CLOUD_ORIGIN = "https://omb-cloud-staging.fly.dev"; /** Which Admin this app signs in to, and the files that depend on it (the sign-in, saved servers with the Cloud's * entry, Files' state, the Cloud's chosen name): staging never reads or writes production's. */ export function cloudTarget(env = process.env) { return env?.OMB_CLOUD_STAGING === "1" ? { origin: STAGING_CLOUD_ORIGIN, staging: true, file: "cloud-account-staging.bin", saved: "cloud-accounts-staging.bin", places: "account-places-staging.json", environments: "environments-staging.json", sync: "cloud-sync-staging", look: "cloud-home-look-staging.json" } : { origin: CLOUD_ORIGIN, staging: false, file: "cloud-account.bin", saved: "cloud-accounts.bin", places: "account-places.json", environments: "environments.json", sync: "cloud-sync", look: "cloud-home-look.json" }; } const TOKEN = /^omc_[A-Za-z0-9_-]{43}$/; const CODE = /^[A-HJ-NP-Z2-9]{5}-[A-HJ-NP-Z2-9]{5}$/; const PRIVATE_CODE = /^[A-Za-z0-9_-]{43}$/; /** What the Admin says of a Cloud of this account's a computer moved to (POST moved-to). */ const MOVED_TO = Object.freeze(["deleted", "ended", "live", "unknown"]); const TIER = /^[a-z][a-z0-9-]{0,23}$/; const REFRESH_MS = 60_000; const STEADY_MS = 15 * 60_000; const SETUP_REFRESH_MS = 15_000; /** While My Cloud sleeps and Files or lending waits for it: the session every 15 s, so they resume soon after it wakes. */ const ASLEEP_REFRESH_MS = 15_000; const WAKE_STATES = Object.freeze(["awake", "waking"]); /** While a checkout this app opened waits for its payment and setup: the session every 3 s (30 minutes at most). */ const WATCH_REFRESH_MS = 3_000; const CHECKING_AFTER = 2; const MAX_ENROLL_SECONDS = 1800; const OFFER_MS = 10 * 60_000, OFFER_RETRY_MS = 30_000; const CHECKOUT_WATCH_MS = 30 * 60_000, CHECKOUT_KEPT_MS = 24 * 3600_000; export const CHECKOUT_SOURCES = Object.freeze(["app_card", "app_howto", "app_menu", "app_settings", "app_routines", "app_pages", "app_whats_new_pro", "app_whats_new_team", "app_onboarding"]); export const UPGRADE_SOURCES = Object.freeze(["app_usage", "app_settings", "app_room"]); const UPGRADE_TIERS = Object.freeze(["starter", "personal", "pro", "max"]); const UPGRADE_REFUSALS = Object.freeze(["payment_failed", "not_subscribed", "already_on_plan", "plan_change_unavailable", "confirmation_required", "price_changed", "rate_limited"]); const CONFIRMATION = /^[A-Za-z0-9_-]{43}$/; /** The cancel sheet's reasons, as the Admin takes them. */ export const CLOUD_CANCEL_ANSWERS = Object.freeze(["feature", "price", "setup", "broken", "later", "other"]); const SURVEY_TEXT_MAX = 2_000, SURVEY_WAIT_MS = 8_000; /** * The cancel sheet's survey as main sends it: one reason and each box trimmed, at most 2,000 characters. Words without a * reason are kept as `other`; null when nothing was said, and for anything that isn't a survey (it is never sent then). */ export function cancelSurvey(input) { if (!input || typeof input !== "object" || Array.isArray(input)) return null; const box = value => typeof value === "string" ? value.trim() : ""; const answer = CLOUD_CANCEL_ANSWERS.includes(input.answer) ? input.answer : null, expected = box(input.expected), improve = box(input.improve); if ((input.answer !== undefined && input.answer !== null && !answer) || expected.length > SURVEY_TEXT_MAX || improve.length > SURVEY_TEXT_MAX) return null; if (!answer && !expected && !improve) return null; return { answer: answer ?? "other", ...(expected ? { expected } : {}), ...(improve ? { improve } : {}) }; } /** Why the Admin won't open another Cloud's checkout now (POST clouds, 409 `addBlocked`; contract v1). */ export const ADD_BLOCKS = Object.freeze(["cap", "checkout_open", "rate", "signups_closed"]); const ADD_INTERVALS = Object.freeze(["month", "year"]); const CHECKOUT_ORIGIN = "https://checkout.dodopayments.com", TEST_CHECKOUT_ORIGIN = "https://test.checkout.dodopayments.com"; /** Only Dodo's own HTTPS checkout origin, with no credentials and no fragment; anything else is never opened. Staging's * Admin sells in Dodo's test mode, so its checkouts are only Dodo's test page. */ export function checkoutDestination(value, { fixture = false, staging = false } = {}) { if (typeof value !== "string" || value.length > 2048) return null; let url; try { url = new URL(value); } catch { return null; } const allowed = fixture ? [CHECKOUT_ORIGIN, TEST_CHECKOUT_ORIGIN] : staging ? [TEST_CHECKOUT_ORIGIN] : [CHECKOUT_ORIGIN]; if (!allowed.includes(url.origin) || url.username || url.password || url.hash || value.includes("#")) return null; return url.href; } // Never accept an address from the renderer. export function cloudOrigin(value = CLOUD_ORIGIN, fixture = false) { const url = new URL(value); if (value !== url.origin || (![CLOUD_ORIGIN, STAGING_CLOUD_ORIGIN].includes(value) && !(fixture && url.protocol === "http:" && ["127.0.0.1", "[::1]"].includes(url.hostname)))) { throw new Error("Invalid MausBot Cloud address."); } return value; } export const createCloudAccountStore = createManagedDesktopStore; // oxlint-disable-next-line no-control-regex const text = (value, max) => typeof value === "string" && value.length > 0 && value.length <= max && !/[\x00-\x1f\x7f]/.test(value); const timestamp = value => Number.isSafeInteger(value) && value > 0; /** Display only: the last verified plan, never an entitlement. */ function planHint(value) { if (!value || typeof value !== "object" || typeof value.active !== "boolean") return null; return { ...(typeof value.tier === "string" && TIER.test(value.tier) ? { tier: value.tier } : {}), active: value.active }; } /** Only the Admin's own JSON "invalid_token" ends a sign-in; a 401/403 page from Cloudflare or a proxy does not. */ const signInEnded = error => [401, 403].includes(error?.status) && error.code === "invalid_token"; const sameHint = (left, right) => (left?.tier ?? null) === (right?.tier ?? null) && (left?.active ?? null) === (right?.active ?? null); /** Whether the Admin opens another Cloud's checkout for this account now, as its session says (`canAdd` and * `addBlocked`, both optional): without its word the answer is yes, and POST clouds says otherwise. */ function addOffer(result) { if (result?.canAdd !== false) return { canAdd: true }; return { canAdd: false, blocked: ADD_BLOCKS.includes(result.addBlocked) ? result.addBlocked : "cap" }; } export function createCloudAccountClient({ store, openBrowser, platform, deviceName, appVersion, origin = CLOUD_ORIGIN, fixture = false, fetch: fetcher = globalThis.fetch, now = Date.now, onState = () => {}, setTimer = setTimeout, clearTimer = clearTimeout, warn = message => console.warn(message), awaitingWake = () => false, t = englishText.t }) { origin = cloudOrigin(origin, fixture); const staging = origin === STAGING_CLOUD_ORIGIN; let grant = null, issued = null, cleanup = null, pending = null, cleanupNeeded = false; let value = { status: "signed-out", message: "restoring" }, generation = 0, timer = null, closed = false, clearing = null, refreshing = null; let verifiedUntil = 0, restoring = false, controller = new AbortController(), failures = 0; let plan = null, savedHint = null; /** The plan's own Cloud that Cancel and Keep act on, as the last verified session names it. */ let renewalCloud = null; let offerCache = null, offerPending = null, waking = false; /** The checkout this app opened, its address kept here for Reopen and Copy link: never published, never asked for again. */ let opened = null; /** Another Cloud this app is buying (POST clouds): its id, name, colour and plan, and its checkout page, kept like `opened`'s. */ let adding = null; /** An email code on its way: the attempt the Admin answered, kept in main only. */ let emailed = null; const newerPlans = new Set(); const lastPlan = () => plan && grant && plan.accountId === grant.account.id ? { ...(plan.tier ? { tier: plan.tier } : {}), active: plan.active } : null; const view = (status, message) => { const known = status === "connected" ? null : lastPlan(); return { status, ...(message ? { message } : {}), ...(grant ? { account: grant.account, deviceId: grant.device.id, expiresAt: grant.expiresAt } : {}), ...(known ? { lastPlan: known } : {}) }; }; const checkout = () => opened && now() - opened.startedAt < CHECKOUT_KEPT_MS ? { plan: opened.plan, startedAt: opened.startedAt, ...(opened.signIn ? { signIn: true } : {}), ...(opened.url ? { link: true } : {}), ...(opened.done ? { done: true } : {}) } : null; const buying = () => adding && now() - adding.startedAt < CHECKOUT_KEPT_MS ? { cloudId: adding.cloudId, name: adding.name, color: adding.color, tier: adding.tier, startedAt: adding.startedAt, ...(adding.url ? { link: true } : {}), ...(adding.done ? { done: true } : {}) } : null; const withCheckout = current => { const open = checkout(), add = buying(), shown = waking && current.status === "connected" ? { ...current, waking: true } : current; const next = { ...shown, ...(open ? { checkout: open } : {}), ...(add ? { adding: add } : {}) }; return staging ? { ...next, staging: true } : next; }; const state = () => { if (value.status === "connected") { if (grant && grant.expiresAt <= now()) return structuredClone(withCheckout(view("reauth-required", "expired"))); if (now() >= verifiedUntil) return structuredClone(withCheckout(view("unavailable", "verification-expired"))); if (failures >= CHECKING_AFTER) return structuredClone(withCheckout({ ...value, checking: true })); } return structuredClone(withCheckout(value)); }; const watching = () => Boolean(opened && now() - opened.startedAt < CHECKOUT_WATCH_MS); const watchingAdd = () => Boolean(adding && !adding.done && now() - adding.startedAt < CHECKOUT_WATCH_MS); const publish = next => { value = next; onState(state()); return state(); }; const record = (saved, hint) => ({ ...saved, ...(hint ? { planHint: hint } : {}) }); const stopTimer = () => { if (timer !== null) clearTimer(timer); timer = null; }; const schedule = (work, delay) => { stopTimer(); if (!closed) { timer = setTimer(() => { timer = null; void work().catch(() => {}); }, Math.max(1, delay)); timer?.unref?.(); } }; const reset = () => { generation++; stopTimer(); controller.abort(); controller = new AbortController(); pending = null; emailed = null; verifiedUntil = 0; failures = 0; return generation; }; // `quiet`: another account is taking over (quiesce to reload): this sign-in's grant is still in memory, and nothing may ask for // it or write it over the one taking its place, whoever asks (a window coming to the front, Settings' Refresh). let quiet = false; const current = stamp => !closed && !quiet && generation === stamp; const versionHeader = text(appVersion, 40) && /^[0-9A-Za-z][0-9A-Za-z.+-]{0,39}$/.test(appVersion) ? { "x-openmausbot-version": appVersion } : {}; async function request(route, { method = "GET", body, token, signal = controller.signal, path = `/api/cloud/desktop/${route}`, timeoutMs = 20_000 } = {}) { const response = await fetcher(`${origin}${path}`, { method, redirect: "error", credentials: "omit", cache: "no-store", headers: { accept: "application/json", ...(body === undefined ? {} : { "content-type": "application/json" }), ...(token ? { authorization: `Bearer ${token}`, ...versionHeader } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]), }); const reader = response.body?.getReader(), chunks = []; let size = 0; try { if (reader) while (true) { const { done, value: chunk } = await reader.read(); if (done) break; size += chunk.byteLength; if (size > 64 * 1024) throw new Error("Cloud response too large."); chunks.push(chunk); } } catch (error) { await reader?.cancel().catch(() => {}); throw error; } finally { reader?.releaseLock(); } let data; try { data = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { if (!response.ok) throw Object.assign(new Error("Cloud request failed."), { status: response.status, opaque: true }); throw new Error("Invalid Cloud response."); } if (!response.ok) throw Object.assign(new Error("Cloud request failed."), { status: response.status, code: data?.error, interval: data?.interval, blocked: data?.addBlocked, ...(typeof data?.checkoutUrl === "string" ? { checkoutUrl: data.checkoutUrl } : {}) }); return data; } function identity(result) { if (result?.cloudContractVersion !== 1 || !timestamp(result.expiresAt) || !text(result.device?.id, 128) || !text(result.account?.id, 128) || !text(result.account?.email, 254)) throw new Error("Invalid Cloud identity."); return { expiresAt: result.expiresAt, device: { id: result.device.id }, account: { id: result.account.id, email: result.account.email } }; } function validateGrant(saved) { if (saved?.origin !== origin || !TOKEN.test(saved.token)) throw new Error("Invalid Cloud credential."); return { origin, token: saved.token, ...identity({ ...saved, cloudContractVersion: 1 }) }; } /** A plan newer than this app is paid with its tier, never a lockout; a malformed tier is no tier. */ function entitlement(input) { const newer = typeof input?.plan === "string" && !["free", "pro"].includes(input.plan) && TIER.test(input.plan); if (!input || !(newer || ["free", "pro"].includes(input.plan)) || !["active", "inactive"].includes(input.status) || !(input.expiresAt === null || timestamp(input.expiresAt)) || !Number.isSafeInteger(input.version) || input.version < 0) throw new Error("Invalid Cloud entitlement."); const plan = input.plan === "free" ? "free" : "pro"; if (input.status === "active" && (plan !== "pro" || input.expiresAt === null || input.expiresAt <= now())) throw new Error("Invalid active Cloud entitlement."); if (newer && !newerPlans.has(input.plan)) { newerPlans.add(input.plan); warn(`[cloud] MausBot Cloud sent plan "${input.plan}", newer than this app; treating it as paid.`); } const tier = typeof input.tier === "string" && TIER.test(input.tier) ? input.tier : newer ? input.plan : undefined; return { plan, ...(tier ? { tier } : {}), status: input.status, expiresAt: input.expiresAt, version: input.version }; } // Security: an upgrade preview's confirmation stays here, never in a page, and is used once. const confirmations = new Map(); async function upgradeFailure(error, signedIn, browser = true) { if (signInEnded(error)) { await refresh().catch(() => {}); return { outcome: "failed" }; } if (!browser && (!error?.status || error.opaque || error.status === 504)) return { outcome: "unconfirmed" }; if (!error?.opaque && UPGRADE_REFUSALS.includes(error?.code)) return { outcome: "refused", code: error.code }; if (browser && grant === signedIn && !error?.opaque && ([404, 405, 501].includes(error?.status) || (error?.status === 503 && error.code === "temporarily_unavailable"))) { await openBrowser(`${origin}/cloud`); return { outcome: "browser" }; } return { outcome: "failed" }; } /** * An Admin without the app's Cancel and Keep (its own 404, 405 or 501) opens the Cloud page's billing instead. Its `not_found` * is the Cloud, not the route: the session is read again. */ async function renewalFailure(error, signedIn) { if (signInEnded(error)) return { outcome: "failed", state: await refresh().catch(() => state()) }; if (grant === signedIn && !error?.opaque && [404, 405, 501].includes(error?.status) && error.code !== "not_found") { await openBrowser(`${origin}/cloud/personal/billing`); return { outcome: "browser" }; } return { outcome: "failed", state: [404, 409].includes(error?.status) ? await refresh().catch(() => state()) : state() }; } async function revoke(previous) { try { await request("session", { method: "DELETE", body: {}, token: previous.token, signal: AbortSignal.timeout(20_000) }); } catch (error) { if (![401, 403].includes(error?.status) || error.opaque) throw error; } } function homeTarget() { const current = state(); const machine = current.status === "connected" ? current.machine : undefined; return machine?.origin && CLOUD_MACHINE_CONNECTABLE.includes(machine.status) ? { origin: machine.origin } : null; } function otherOwnCloud(cloudId) { const current = state(); return current.status === "connected" ? otherOwnClouds(current.clouds).find(cloud => cloud.id === cloudId) ?? null : null; } function forget({ quiet = false } = {}) { if (clearing) return clearing; const previous = grant ?? issued ?? cleanup, stamp = reset(); grant = null; issued = null; cleanup = previous; cleanupNeeded = true; plan = null; savedHint = null; opened = null; adding = null; renewalCloud = null; if (!quiet) publish({ status: "signed-out" }); const operation = (async () => { const [persisted, revoked] = await Promise.allSettled([Promise.resolve().then(() => store.write(null)), previous ? revoke(previous) : Promise.resolve()]); if (persisted.status === "fulfilled") { cleanup = null; cleanupNeeded = false; } if (!current(stamp)) return state(); if (persisted.status === "rejected") return publish({ status: "unavailable", message: "signout-storage-failed" }); if (quiet) return state(); return publish({ status: "signed-out", ...(revoked.status === "rejected" ? { message: "signout-local-only" } : {}) }); })().finally(() => { if (clearing === operation) clearing = null; }); clearing = operation; return operation; } const signOut = () => forget(); async function synchronize(stamp) { if (!grant || !current(stamp)) return state(); if (grant.expiresAt <= now()) { stopTimer(); return publish(view("reauth-required", "expired")); } try { const previous = grant, result = await request("session", { token: previous.token }); if (!current(stamp) || grant !== previous) return state(); const next = identity(result); if (next.device.id !== previous.device.id || next.account.id !== previous.account.id || next.account.email !== previous.account.email || next.expiresAt <= now()) { throw Object.assign(new Error("Cloud identity changed."), { status: 401, code: "invalid_token" }); } const access = entitlement(result.entitlement); const machine = parseCloudSummary(result.cloud), teams = parseCloudTeams(result.teams), landing = parseCloudLanding(result.landing, teams); const purchase = parseCloudPurchase(result.cloud?.purchase); const trial = parseCloudTrial(result.cloud?.trial), credit = parseCloudCredit(result.cloud?.credit), offer = parseCloudOffer(result.offer); const usage = access.status === "active" ? parseCloudUsage(result.cloud?.usage) : null, room = usage ? parseCloudRoom(result.cloud?.room) : null; const clouds = parseCloudList(result.clouds), more = clouds ? addOffer(result) : null, latest = clouds ? releaseVersion(result.latestVersion) : null; const organizations = clouds ? parseOrganizations(result.organizations) : null; const { cloudId: renewingCloud = null, ...renewal } = parseCloudRenewal(result.clouds) ?? {}; const hint = access.plan === "pro" ? { ...(access.tier ? { tier: access.tier } : {}), active: access.status === "active" } : null; if (next.expiresAt !== previous.expiresAt) { const replacement = { ...previous, expiresAt: next.expiresAt }; await store.write(record(replacement, hint)); if (!current(stamp) || grant !== previous) return state(); grant = replacement; savedHint = hint; } else if (!sameHint(savedHint, hint)) { try { await store.write(record(previous, hint)); savedHint = hint; } catch {} if (!current(stamp) || grant !== previous) return state(); } plan = hint ? { accountId: grant.account.id, ...hint } : null; renewalCloud = renewingCloud; failures = 0; // A checkout the Admin still holds open, with none kept here (this app restarted mid-checkout): watched as this // app's own from first sight, so payment, setup and "ready" are followed as closely as before the restart. if (!opened && offer?.checkout && offer.checkout.openUntil > now() && access.status !== "active") { opened = { plan: offer.checkout.plan, source: null, startedAt: now() }; } // Another Cloud at checkout with none kept here (this app restarted mid-checkout, or it was bought elsewhere): watched // as this app's own, so "ready" is said for it too. const waitingCloud = otherOwnClouds(clouds).find(cloud => cloud.status === "checkout"); if (!adding && waitingCloud) adding = { cloudId: waitingCloud.id, name: waitingCloud.name, color: waitingCloud.color, tier: waitingCloud.tier, startedAt: now(), url: null }; // A checkout kept from the Admin's "one is still open" answer, before the session listed its Cloud: that Cloud now. else if (adding && adding.cloudId === null && waitingCloud) adding = { ...adding, cloudId: waitingCloud.id, name: waitingCloud.name, color: waitingCloud.color, tier: waitingCloud.tier ?? adding.tier }; verifiedUntil = Math.min(grant.expiresAt, now() + STEADY_MS, access.status === "active" && access.expiresAt !== null ? access.expiresAt : Infinity); publish({ ...view("connected"), entitlement: access, ...(machine ? { machine } : {}), ...(teams.length ? { teams } : {}), ...(landing ? { landing } : {}), ...(purchase ? { purchase } : {}), ...(trial ? { trial } : {}), ...(credit ? { credit } : {}), ...(offer ? { offer } : {}), ...(usage ? { usage } : {}), ...(room ? { room } : {}), ...(renewingCloud ? { renewal } : {}), ...(clouds ? { clouds, addCloud: more } : {}), ...(organizations ? { organizations } : {}), ...(latest ? { latestVersion: latest } : {}), verifiedAt: now(), verifiedUntil }); const planCloud = teamPlan(value) ? planTeam(value)?.machine ?? null : machine; const settingUp = planCloud?.status === "provisioning" || (access.status === "active" && !planCloud) || Boolean(purchase) || otherOwnClouds(clouds).some(cloud => cloud.status === "setting_up"); const added = adding ? clouds?.find(cloud => cloud.id === adding.cloudId) ?? null : null; const paying = watchingAdd() && Boolean(adding.url) && (!added || added.status === "checkout"); const delay = (watching() && planCloud?.status !== "ready") || paying ? WATCH_REFRESH_MS : settingUp ? SETUP_REFRESH_MS : awaitingWake() ? ASLEEP_REFRESH_MS : REFRESH_MS; schedule(refresh, Math.min(delay, verifiedUntil - now())); } catch (error) { if (!current(stamp)) return state(); if (signInEnded(error)) { verifiedUntil = 0; failures = 0; stopTimer(); return publish(view("reauth-required", "access-ended")); } failures++; if (value.status === "connected") publish(value); else publish(view("unavailable", "unreachable")); schedule(refresh, Math.min(retryDelay(failures), grant.expiresAt - now())); } return state(); } function refresh() { if (clearing) return clearing; if (refreshing?.stamp === generation) return refreshing.operation; const entry = { stamp: generation, operation: null }; entry.operation = synchronize(generation).finally(() => { if (refreshing === entry) refreshing = null; }); refreshing = entry; return entry.operation; } /** A token the Admin just issued: stored where every sign-in goes, then this computer is signed in with it. One no * longer wanted (the sign-in was superseded or cancelled meanwhile) is revoked, never left behind. */ async function adopt(result, stamp, wanted = () => true) { const next = validateGrant({ origin, token: result?.accessToken, ...identity(result) }); if (!current(stamp) || !wanted()) { await revoke(next).catch(() => {}); return false; } if (next.expiresAt <= now()) throw new Error("Expired Cloud credential."); issued = next; await store.write(next); if (!current(stamp)) return false; grant = next; issued = null; pending = null; emailed = null; savedHint = null; if (plan?.accountId !== next.account.id) plan = null; return true; } async function poll() { const attempt = pending, stamp = generation; if (!attempt || !current(stamp)) return state(); if (now() >= attempt.expiresAt) { pending = null; opened = null; return publish({ status: "signed-out", message: "enrollment-expired" }); } try { const result = await request("token", { method: "POST", body: { deviceCode: attempt.deviceCode } }); if (!(await adopt(result, stamp))) return state(); const intent = opened; const signedIn = await refresh(); if (intent && current(stamp) && opened === intent && signedIn.status === "connected") { if (signedIn.entitlement?.plan !== "free" || signedIn.purchase || signedIn.machine) { opened = null; return publish(value); } if (!signedIn.offer) return (await openCheckout(intent.plan, intent.source).catch(() => ({ state: state() }))).state; } return signedIn; } catch (error) { if (!current(stamp)) return state(); if (issued) return signOut(); if (error?.code === "slow_down") attempt.interval = Math.min(60_000, Math.max(attempt.interval + 5000, Number.isSafeInteger(error.interval) ? error.interval * 1000 : 0)); else if (["access_denied", "expired_token", "invalid_grant"].includes(error?.code)) { pending = null; opened = null; return publish({ status: "signed-out", message: "enrollment-ended" }); } else if (error?.status && !error.opaque && error.code !== "authorization_pending" && error.status !== 429) { pending = null; opened = null; return publish({ status: "signed-out", message: "signin-failed" }); } schedule(poll, Math.min(attempt.interval, attempt.expiresAt - now())); return state(); } } async function begin(intent = null) { if (closed || restoring || clearing || grant || issued || cleanupNeeded) throw new Error("Sign out before starting another Cloud connection."); const stamp = reset(); opened = intent ? { plan: intent.plan, source: intent.source, startedAt: now(), signIn: true } : null; publish({ status: "connecting" }); try { if (!text(deviceName, 100) || !["darwin", "win32", "linux"].includes(platform)) throw new Error("Invalid desktop."); const result = await request("authorize", { method: "POST", body: { deviceName, platform, ...(text(appVersion, 40) ? { appVersion } : {}) } }); if (!current(stamp)) return state(); if (result.cloudContractVersion !== 1 || !PRIVATE_CODE.test(result.deviceCode) || !CODE.test(result.userCode) || result.verificationUriComplete !== `${origin}/cloud/desktop?code=${result.userCode}` || !Number.isSafeInteger(result.expiresIn) || result.expiresIn < 1 || result.expiresIn > MAX_ENROLL_SECONDS || !Number.isSafeInteger(result.interval) || result.interval < 5 || result.interval > 60) throw new Error("Invalid Cloud authorization."); const next = intent ? `&${new URLSearchParams({ next: "checkout", plan: intent.plan, src: intent.source })}` : ""; pending = { deviceCode: result.deviceCode, verificationUri: `${result.verificationUriComplete}${next}`, expiresAt: now() + result.expiresIn * 1000, interval: result.interval * 1000 }; publish({ status: "connecting", enrollment: { userCode: result.userCode, expiresAt: pending.expiresAt } }); await openBrowser(pending.verificationUri); if (current(stamp)) schedule(poll, pending.interval); } catch { if (current(stamp)) { pending = null; opened = null; publish({ status: "signed-out", message: "signin-failed" }); } } return state(); } const signInBlocked = () => closed || restoring || clearing || grant || issued || cleanupNeeded; /** Sends the 8-digit code to `email`. Nothing is signed in yet; the answer says what the sheet shows next. */ async function emailStart(email) { if (signInBlocked() || value.status === "connecting") throw new Error("Sign out before starting another Cloud connection."); const address = cleanEmail(email), app = signInIdentity({ deviceName, platform, appVersion }); if (!address) return { outcome: "invalid-email" }; if (!app) return { outcome: "browser" }; const stamp = generation; // A code already sent stays good until a new one is: a refused Resend (too many, offline) must not lose it. if (emailed && emailed.email !== address) emailed = null; try { const started = parseSignInStart(await request(null, { method: "POST", path: SIGN_IN_START, body: { email: address, ...app } })); if (!current(stamp)) return { outcome: "failed" }; if (!started) return { outcome: "failed" }; emailed = { attempt: started.attempt, email: address, expiresAt: now() + started.expiresIn * 1000, wrong: 0 }; return { outcome: "sent", email: address, expiresAt: emailed.expiresAt, resendAt: now() + started.resendAfter * 1000, signupOpen: started.signupOpen }; } catch (error) { return current(stamp) ? { outcome: startFailure(error) } : { outcome: "failed" }; } } /** Checks the code; on success the token is stored like the browser sign-in's and the session is read at once. */ async function emailVerify(code) { const attempt = emailed, digits = cleanCode(code); if (!attempt || now() >= attempt.expiresAt) { emailed = null; return { outcome: "expired" }; } if (!digits) return { outcome: "wrong-code", triesLeft: SIGN_IN_TRIES - attempt.wrong }; if (signInBlocked()) throw new Error("Sign out before starting another Cloud connection."); const stamp = generation; try { const result = await request(null, { method: "POST", path: SIGN_IN_VERIFY, body: { attempt: attempt.attempt, code: digits } }); if (!(await adopt(result, stamp, () => emailed === attempt))) return { outcome: "failed" }; return { outcome: "signed-in", state: await refresh() }; } catch (error) { if (!current(stamp)) return { outcome: "failed" }; if (issued) { await signOut(); return { outcome: "failed" }; } const outcome = verifyFailure(error); if (outcome === "wrong-code" && emailed === attempt) { attempt.wrong++; if (attempt.wrong < SIGN_IN_TRIES) return { outcome, triesLeft: SIGN_IN_TRIES - attempt.wrong }; } if (emailed === attempt) emailed = null; return { outcome: outcome === "wrong-code" ? "expired" : outcome }; } } async function openCheckout(plan, source) { if (typeof plan !== "string" || !TIER.test(plan) || !CHECKOUT_SOURCES.includes(source)) throw new Error("Invalid Cloud checkout."); const current = state(); if (current.status === "signed-out" && !grant) return { outcome: "signing-in", state: await begin({ plan, source }) }; if (current.status !== "connected" || !grant) throw new Error("Sign in to MausBot Cloud before choosing a plan."); const signedIn = grant; let destination; try { const result = await request("checkout", { method: "POST", body: { plan, source }, token: signedIn.token }); destination = checkoutDestination(result?.url, { fixture, staging }); if (result?.cloudContractVersion !== 1 || !destination) return { outcome: "failed", state: state() }; } catch (error) { if (!error?.opaque && error?.status === 409) return { outcome: "conflict", state: await refresh() }; if (!error?.opaque && error?.status === 429) return { outcome: "rate-limited", state: state() }; if (signInEnded(error)) return { outcome: "failed", state: await refresh() }; if (![404, 405, 501].includes(error?.status)) return { outcome: "failed", state: state() }; destination = `${origin}/cloud?${new URLSearchParams({ checkout: "start", plan, src: source })}`; } if (grant !== signedIn) return { outcome: "failed", state: state() }; await openBrowser(destination); opened = { plan, source, startedAt: now(), url: destination }; publish(value); schedule(refresh, WATCH_REFRESH_MS); return { outcome: "opened", state: state() }; } /** Reopen checkout: the same page again, never a new one from the Admin; only with none kept here (the browser * sign-in led to it, or the app restarted) is the Admin asked, and it reopens the open one. Watches 30 minutes more. */ async function reopenCheckout(plan, source) { const kept = opened; if (!kept?.url) return openCheckout(kept?.plan ?? plan, kept?.source ?? source); await openBrowser(kept.url); if (opened !== kept) return { outcome: "failed", state: state() }; opened = { ...kept, startedAt: now() }; publish(value); schedule(refresh, WATCH_REFRESH_MS); return { outcome: "opened", state: state() }; } /** A sign-in that may only be locked in the keychain is kept, never removed, and read again shortly. */ async function restoreFailed(stamp, unreadable) { cleanupNeeded = true; if (unreadable) { const removed = await store.write(null).then(() => true, () => false); if (!current(stamp)) return state(); if (removed) { cleanupNeeded = false; failures = 0; return publish({ status: "signed-out", message: "restore-removed" }); } } failures++; if (value.message !== "restore-failed") publish({ status: "unavailable", message: "restore-failed" }); schedule(start, retryDelay(failures)); return state(); } async function start() { const stamp = generation; restoring = true; verifiedUntil = 0; let saved, restored = null, failed = false, unreadable = false; try { saved = await store.read(); } catch (error) { failed = true; unreadable = unreadableRecord(error); } if (!failed) try { restored = saved ? validateGrant(saved) : null; } catch { failed = unreadable = true; } restoring = false; if (!current(stamp)) return state(); if (failed) return restoreFailed(stamp, unreadable); grant = restored; cleanupNeeded = false; failures = 0; savedHint = grant ? planHint(saved.planHint) : null; plan = grant && savedHint ? { accountId: grant.account.id, ...savedHint } : null; if (!grant) return ["restoring", "restore-failed"].includes(value.message) ? publish({ status: "signed-out" }) : state(); return refresh(); } return { state, start, begin: () => begin(), emailStart, emailVerify, emailCancel() { emailed = null; }, checkout: openCheckout, reopenCheckout, /** For main's clipboard only: the kept checkout page, while it is the one being waited on. */ checkoutLink: () => (opened?.url && now() - opened.startedAt < CHECKOUT_KEPT_MS ? opened.url : null), checkoutPending: watching, /** The Cloud this checkout bought was opened, announced or shown ready: the purchase is over (Add a Cloud… says one Cloud for now). */ checkoutDone() { if (opened && !opened.done) { opened = { ...opened, done: true }; publish(value); } }, async signInAgain() { if (state().status !== "reauth-required") throw new Error("This computer is still signed in to MausBot Cloud."); publish({ status: "connecting" }); const forgotten = await forget({ quiet: true }); if (forgotten.status === "unavailable") return forgotten; return begin(); }, async reopen() { if (pending && value.status === "connecting" && pending.expiresAt > now()) await openBrowser(pending.verificationUri); return state(); }, cancel: () => { if (!pending && value.status !== "connecting") return Promise.resolve(state()); opened = null; return signOut(); }, refresh, signOut, async openDashboard() { await openBrowser(`${origin}/cloud`); return state(); }, async upgradePreview(plan, source) { if (!UPGRADE_TIERS.includes(plan) || !UPGRADE_SOURCES.includes(source)) throw new Error("Invalid Cloud upgrade."); const signedIn = grant; if (!signedIn || state().status !== "connected") return { outcome: "failed" }; let result; try { result = await request("upgrade/preview", { method: "POST", body: { plan, source }, token: signedIn.token, timeoutMs: 60_000 }); } catch (error) { return upgradeFailure(error, signedIn); } if (grant !== signedIn || result?.cloudContractVersion !== 1) return { outcome: "failed" }; if (["upgraded", "pending"].includes(result.status)) { await refresh(); return { outcome: "settled", status: result.status, tier: TIER.test(result.tier) ? result.tier : plan }; } const cents = value => Number.isSafeInteger(value) && value >= 0 && value <= 10_000_000; if (result.tier !== plan || !cents(result.price) || result.price < 1 || !cents(result.chargeNow) || !cents(result.tax ?? 0) || result.currency !== "USD" || typeof result.trial !== "boolean" || !CONFIRMATION.test(result.confirmation ?? "") || !timestamp(result.expiresAt)) return { outcome: "failed" }; confirmations.set(plan, { token: result.confirmation, expiresAt: Math.min(result.expiresAt, now() + 10 * 60_000), account: signedIn.account.id }); return { outcome: "preview", preview: { plan, ...(TIER.test(result.from ?? "") ? { from: result.from } : {}), trial: result.trial, price: result.price, chargeNow: result.chargeNow, tax: result.tax ?? 0, ...(timestamp(result.nextBillingAt) ? { nextBillingAt: result.nextBillingAt } : {}), expiresAt: result.expiresAt } }; }, async upgrade(plan, source) { if (!UPGRADE_TIERS.includes(plan) || !UPGRADE_SOURCES.includes(source)) throw new Error("Invalid Cloud upgrade."); const signedIn = grant, agreed = confirmations.get(plan); confirmations.delete(plan); if (!signedIn || state().status !== "connected") return { outcome: "failed" }; if (!agreed || agreed.account !== signedIn.account.id || agreed.expiresAt <= now()) return { outcome: "refused", code: "confirmation_required" }; let result; try { result = await request("upgrade", { method: "POST", body: { plan, confirmation: agreed.token, source }, token: signedIn.token, timeoutMs: 120_000 }); } catch (error) { const failure = await upgradeFailure(error, signedIn, false); await refresh().catch(() => {}); return failure; } await refresh().catch(() => {}); if (result?.cloudContractVersion !== 1 || !["upgraded", "pending"].includes(result.status)) return { outcome: "failed" }; return { outcome: result.status, tier: TIER.test(result.tier ?? "") ? result.tier : plan }; }, /** * Cancel trial / Cancel plan: the survey first when the person said anything (given SURVEY_WAIT_MS, and let go when it * fails or is refused), then renewal off for the plan's own Cloud, named by the verified session, never by a page. * `cancelled`: when it ends, from the Admin's answer (`renewal`), and whether the survey was kept; `browser`: this Admin * can't cancel from the app yet, so its Cloud page's billing opened instead; `failed`: nothing changed. */ async cancelPlan(input) { const signedIn = grant, cloudId = renewalCloud; if (!signedIn || !cloudId || state().status !== "connected") return { outcome: "failed", state: state() }; const survey = cancelSurvey(input); let surveyed = false; if (survey) { try { await request("feedback", { method: "POST", body: { cloudId, ...survey }, token: signedIn.token, timeoutMs: SURVEY_WAIT_MS }); surveyed = true; } catch {} } if (grant !== signedIn) return { outcome: "failed", state: state() }; let result; try { result = await request(`clouds/${cloudId}/cancel`, { method: "POST", body: {}, token: signedIn.token, timeoutMs: 60_000 }); } catch (error) { return renewalFailure(error, signedIn); } const { cloudId: answered, ...renewal } = result?.cloudContractVersion === 1 ? parseCloudRenewal([result.cloud]) ?? {} : {}; return { outcome: "cancelled", renewal: answered === cloudId ? renewal : null, surveyed, state: await refresh().catch(() => state()) }; }, /** Keep my plan: renewal back on for the same Cloud, as `cancelPlan` names it. */ async keepPlan() { const signedIn = grant, cloudId = renewalCloud; if (!signedIn || !cloudId || state().status !== "connected") return { outcome: "failed", state: state() }; try { await request(`clouds/${cloudId}/keep`, { method: "POST", body: {}, token: signedIn.token, timeoutMs: 60_000 }); } catch (error) { return renewalFailure(error, signedIn); } return { outcome: "kept", state: await refresh().catch(() => state()) }; }, async offer() { const current = state(); if (current.status === "connected" && current.offer) return current.offer; if (!offerCache || now() >= offerCache.until) { offerPending ??= request(null, { path: "/api/public/config", signal: AbortSignal.timeout(10_000) }).then( config => { const offer = parsePublicOffer(config); offerCache = { value: offer, until: now() + (offer ? OFFER_MS : OFFER_RETRY_MS) }; return offer; }, () => { offerCache = { value: null, until: now() + OFFER_RETRY_MS }; return null; }, ).finally(() => { offerPending = null; }); await offerPending; } return current.status === "connected" ? withoutTrial(offerCache.value) : offerCache.value; }, /** Not now on the room card: the Admin offers this Cloud nothing more for two weeks. The Cloud is the session's own. */ async roomNotNow(id) { const signedIn = grant; if (!signedIn || state().status !== "connected") return false; try { await request("room", { method: "POST", body: { ...(typeof id === "string" ? { id } : {}), answer: "not_now" }, token: signedIn.token }); } catch { return false; } await refresh().catch(() => {}); return true; }, /** Restart now, after an upgrade: the bigger size goes at once instead of the next idle moment (`restarting`), or the * Cloud already has it (`current`); `failed` when the Admin couldn't be asked. */ async restartNow() { const signedIn = grant; if (!signedIn || state().status !== "connected") return "failed"; try { const result = await request("resize-now", { method: "POST", body: {}, token: signedIn.token, timeoutMs: 30_000 }); return ["restarting", "current"].includes(result?.state) ? result.state : "failed"; } catch { return "failed"; } }, async interest(kind) { if (kind !== "another_cloud") throw new Error("Invalid Cloud interest."); const signedIn = grant; if (!signedIn || state().status !== "connected") throw new Error("Sign in to MausBot Cloud first."); const result = await request("interest", { method: "POST", body: { kind }, token: signedIn.token }); if (result?.ok !== true) throw new Error("Invalid Cloud response."); }, homeTarget, /** The Admin's last verified word on a Cloud whose plan sleeps, kept while a re-check fails; null for any other. */ power: () => (value.status === "connected" ? value.machine?.power ?? null : null), /** The same for the Cloud at `origin`: My Cloud's `cloud.power`, or another's from the Clouds list (contract v1, Oct 30). */ powerOf: origin => { if (value.status !== "connected" || typeof origin !== "string" || !origin) return null; if (value.machine?.origin === origin) return value.machine.power ?? null; return (value.clouds ?? []).find(cloud => cloud.origin === origin)?.power ?? null; }, /** Starts a sleeping Cloud through the Admin (`awake` once started, `waking` if it is still starting), then reads the * session again so Files and lending resume. Null when there is nothing to wake or the Admin can't say. */ async wakeHome() { const current = grant, power = value.status === "connected" ? value.machine?.power : null; if (!current || !power || power.state === "awake") return null; let woke = null; try { const result = await request("wake", { method: "POST", body: {}, token: current.token }); woke = WAKE_STATES.includes(result?.state) ? result.state : null; } catch {} await refresh().catch(() => {}); return woke; }, /** Opening a sleeping Cloud takes a moment: the state says so meanwhile. */ setWaking(on) { if (waking === (on === true)) return; waking = on === true; publish(value); }, async growDisk(sizeGb) { const current = grant; if (!current || state().status !== "connected") throw new Error("My Cloud is not ready yet."); if (!Number.isSafeInteger(sizeGb) || sizeGb < 1 || sizeGb > 10_000) throw new Error("Invalid Cloud disk size."); let result; try { result = await request("disk", { method: "POST", body: { sizeGb }, token: current.token }); } catch (error) { if ([404, 405, 501].includes(error?.status)) return { supported: false }; if ([409, 422].includes(error?.status)) return { supported: true, refused: true }; throw error; } const disk = result?.disk; if (!disk || !Number.isSafeInteger(disk.gb) || !Number.isSafeInteger(disk.maxGb) || disk.gb < 1 || disk.maxGb < disk.gb) throw new Error("Invalid Cloud disk response."); return { supported: true, disk: { gb: disk.gb, maxGb: disk.maxGb } }; }, /** The code goes to main only, for one navigation, and is not kept. `cloudId`: another Cloud of the person's own * that the verified session lists (the Admin answers 404 once it is not theirs any more: `gone`); none is My Cloud, * which an older Admin pairs from the same empty body. The grant must be for that Cloud's own address. */ async pairHome(cloudId = null) { const current = grant; if (cloudId !== null) { const cloud = otherOwnCloud(cloudId); if (!current || !cloud) throw Object.assign(new Error(t("native.cloud.otherGone")), { gone: true }); if (!cloud.origin || !["ready", "offline"].includes(cloud.status)) throw new Error(t("native.cloud.otherNotReady", { name: cloud.name })); let result; try { result = await request("pairing", { method: "POST", body: { cloudId }, token: current.token }); } catch (error) { if (error?.status === 404 && !error.opaque) { void refresh().catch(() => {}); throw Object.assign(new Error(t("native.cloud.otherNoLongerYours", { name: cloud.name })), { gone: true }); } if (error?.status === 409) throw new Error(t("native.cloud.otherNotRunning", { name: cloud.name })); throw error; } const pairing = parsePairingGrant(result, cloud.origin, now()); if (!pairing) throw new Error("Invalid Cloud pairing response."); return pairing; } const target = homeTarget(); if (!target || !current) throw new Error(t("native.cloud.notReady")); let result; try { result = await request("pairing", { method: "POST", body: {}, token: current.token }); } catch (error) { const why = cloudUnreachableText(await refresh().catch(() => state()), target.origin); throw why ? new Error(why) : error; } const pairing = parsePairingGrant(result, target.origin, now()); if (!pairing) throw new Error("Invalid Cloud pairing response."); return pairing; }, /** `origin` is the address of a Cloud the person owns (My Cloud or another of theirs), by the verified session only. */ isOwnCloud(origin) { const current = state(); if (typeof origin !== "string" || !origin || current.status !== "connected") return false; return current.machine?.origin === origin || (current.clouds ?? []).some(cloud => ownCloud(cloud) && cloud.origin === origin); }, /** Buys another Cloud (POST clouds; contract v1): the plan, how often it is paid, its name and its swatch. The * Admin's checkout opens in the browser and its page is kept here for Reopen and Copy link, as the first Cloud's is; * the session is then read every few seconds until the Cloud is paid for. `unsupported`: this Admin sells one Cloud * per account; `blocked` with the Admin's reason. */ async addCloud({ tier, interval = "month", name, color = null, source = null } = {}) { const cleanName = cleanCloudListName(name); if (typeof tier !== "string" || !TIER.test(tier) || !ADD_INTERVALS.includes(interval) || !cleanName || !(color === null || CLOUD_SWATCHES.includes(color)) || !(source === null || CHECKOUT_SOURCES.includes(source))) { throw new Error(t("native.cloud.invalidAdd")); } const signedIn = grant; if (!signedIn || state().status !== "connected") return { outcome: "failed" }; if (!value.clouds) return { outcome: "unsupported" }; if (value.addCloud?.canAdd === false) return { outcome: "blocked", blocked: value.addCloud.blocked }; let result; try { result = await request("clouds", { method: "POST", body: { tier, interval, name: cleanName, ...(color ? { color } : {}), ...(source ? { source } : {}) }, token: signedIn.token }); } catch (error) { if (signInEnded(error)) { await refresh().catch(() => {}); return { outcome: "failed" }; } // The Admin's own reason wins, whatever the status (409 cap or open checkout, 429 rate, 503 sign-ups closed). const blocked = !error?.opaque && ADD_BLOCKS.includes(error?.blocked) ? error.blocked : null; if (blocked === "checkout_open") { // Another Cloud's checkout is still open: its page is kept here, as one this app opened, to reopen or copy. const open = checkoutDestination(error.checkoutUrl, { fixture, staging }); const listed = otherOwnClouds(value.clouds).find(cloud => cloud.status === "checkout") ?? null; if (open && grant === signedIn) { adding = { cloudId: listed?.id ?? null, name: listed?.name ?? cleanName, color: listed ? listed.color : color, tier: listed?.tier ?? tier, startedAt: now(), url: open }; publish(value); schedule(refresh, WATCH_REFRESH_MS); } } if (blocked) return { outcome: "blocked", blocked }; if (!error?.opaque && error?.status === 409) return { outcome: "blocked", blocked: "cap" }; if (!error?.opaque && error?.status === 429) return { outcome: "blocked", blocked: "rate" }; if ([404, 405, 501].includes(error?.status)) return { outcome: "unsupported" }; return { outcome: "failed" }; } const destination = checkoutDestination(result?.checkoutUrl, { fixture, staging }); if ((result?.cloudContractVersion !== undefined && result.cloudContractVersion !== 1) || typeof result?.cloudId !== "string" || !CLOUD_ID.test(result.cloudId) || !destination) return { outcome: "failed" }; if (grant !== signedIn) return { outcome: "failed" }; await openBrowser(destination); adding = { cloudId: result.cloudId, name: cleanName, color, tier, startedAt: now(), url: destination }; publish(value); schedule(refresh, WATCH_REFRESH_MS); return { outcome: "opened", state: state() }; }, /** A new organization on the Team plan this account pays for (POST organizations, contract v1): its name and swatch, and * no checkout. `added` once the Admin made it (the session is read again, so the rail shows it being set up); `blocked` * with the Admin's reason; `taken` when another of the plan's has that name; `unsupported` from an Admin without it. */ async addOrganization({ name, color = null } = {}) { const cleanName = cleanCloudListName(name); if (!cleanName || !(color === null || CLOUD_SWATCHES.includes(color))) throw new Error(t("native.cloud.invalidOrganization")); const signedIn = grant; if (!signedIn || state().status !== "connected") return { outcome: "failed" }; if (!value.organizations) return { outcome: "unsupported" }; let result; try { result = await request("organizations", { method: "POST", body: { name: cleanName, ...(color ? { color } : {}) }, token: signedIn.token }); } catch (error) { if (signInEnded(error)) { await refresh().catch(() => {}); return { outcome: "failed" }; } if (!error?.opaque && ORGANIZATION_BLOCKS.includes(error?.blocked)) return { outcome: "blocked", blocked: error.blocked }; if (!error?.opaque && error?.code === "name_taken") return { outcome: "taken" }; if (!error?.opaque && error?.status === 429) return { outcome: "blocked", blocked: "rate" }; if ([404, 405, 501].includes(error?.status)) return { outcome: "unsupported" }; return { outcome: "failed" }; } if (grant === signedIn) await refresh().catch(() => {}); return { outcome: "added", ...(typeof result?.cloud?.id === "string" && CLOUD_ID.test(result.cloud.id) ? { cloudId: result.cloud.id } : {}) }; }, /** A Cloud's colour (PATCH clouds/:id, contract v1), which only its owner or its team's Super admin and Admins * change: `saved` once the Admin keeps it (the session is read again, so every place follows); `refused` when the * Admin says it is someone else's to change; `unsupported` from an Admin without the route or the list. */ async setCloudColor(cloudId, color) { if (typeof cloudId !== "string" || !CLOUD_ID.test(cloudId) || !(color === null || CLOUD_SWATCHES.includes(color))) throw new Error(t("native.cloud.invalidColor")); const signedIn = grant; if (!signedIn || state().status !== "connected") return { outcome: "failed" }; if (!value.clouds?.some(cloud => cloud.id === cloudId && cloud.managed !== false)) return { outcome: "unsupported" }; try { await request(`clouds/${encodeURIComponent(cloudId)}`, { method: "PATCH", body: { color }, token: signedIn.token }); } catch (error) { if (signInEnded(error)) { await refresh().catch(() => {}); return { outcome: "failed" }; } if (!error?.opaque && error?.status === 403) return { outcome: "refused" }; if ([404, 405, 501].includes(error?.status)) return { outcome: "unsupported" }; return { outcome: "failed" }; } if (grant === signedIn) await refresh().catch(() => {}); return { outcome: "saved" }; }, /** Remove from list (POST clouds/:id/hide, contract v1): one of the person's own Clouds the verified session says they may * take off their list (`removable`: it ended, or was never paid for). Only the list changes on the Admin; its files still go * when they were going to. `removed` once the Admin did (the session is read again, so every list here follows); `refused` * when it says that Cloud can't be removed now; `gone` when it is not theirs any more; `unsupported` from an Admin without * Remove from list (whose session never says `removable`). */ async hideCloud(cloudId) { if (typeof cloudId !== "string" || !CLOUD_ID.test(cloudId)) throw new Error(t("native.cloud.invalidRemove")); const signedIn = grant; if (!signedIn || state().status !== "connected") return { outcome: "failed" }; if (!value.clouds?.some(cloud => cloud.id === cloudId && cloud.removable === true)) return { outcome: "unsupported" }; try { await request(`clouds/${encodeURIComponent(cloudId)}/hide`, { method: "POST", body: {}, token: signedIn.token }); } catch (error) { if (signInEnded(error)) { await refresh().catch(() => {}); return { outcome: "failed" }; } if (!error?.opaque && error?.code === "not_removable") { await refresh().catch(() => {}); return { outcome: "refused" }; } if (!error?.opaque && error?.code === "not_found") { await refresh().catch(() => {}); return { outcome: "gone" }; } if ([404, 405, 501].includes(error?.status)) return { outcome: "unsupported" }; return { outcome: "failed" }; } if (grant === signedIn) await refresh().catch(() => {}); return { outcome: "removed" }; }, /** The Cloud being bought's checkout, again: the same page while it is kept here; false when there is none. */ async reopenAdd() { const kept = adding; if (!kept?.url) return false; await openBrowser(kept.url); if (adding !== kept) return false; adding = { ...kept, startedAt: now() }; publish(value); schedule(refresh, WATCH_REFRESH_MS); return true; }, /** For main's clipboard only: that checkout's page. */ addLink: () => (adding?.url && now() - adding.startedAt < CHECKOUT_KEPT_MS ? adding.url : null), /** The Cloud this app bought was opened or announced: that purchase is over. */ addDone() { if (adding && !adding.done) { adding = { ...adding, done: true }; publish(value); } }, /** A one-time code for a team's Cloud, signed by the Admin with this person's membership: main only, for one * navigation. The Admin answers 404 once they are no longer in the team (`gone`), 409 while its Cloud isn't running. */ async pairTeam(teamId) { const current = grant, team = value.status === "connected" ? value.teams?.find(entry => entry.id === teamId) ?? null : null; if (!current || !team) throw Object.assign(new Error("You're no longer in this team."), { gone: true }); const origin = team.machine?.origin; if (!origin || !CLOUD_MACHINE_CONNECTABLE.includes(team.machine.status)) throw new Error(`${team.name} isn't ready to open yet.`); let result; try { result = await request(`teams/${encodeURIComponent(teamId)}/pairing`, { method: "POST", body: {}, token: current.token }); } catch (error) { if (error?.status === 404 && !error.opaque) { void refresh().catch(() => {}); throw Object.assign(new Error(`You're no longer in ${team.name}.`), { gone: true }); } if (error?.status === 409) throw new Error(`${team.name} isn't running right now.`); throw error; } const pairing = parsePairingGrant(result, origin, now()); if (!pairing) throw new Error("Invalid Cloud pairing response."); return pairing; }, /** A Cloud that ended and that the Admin still holds, started for its owner's export only: a one-time code for it * (main only, not kept), `starting` while it starts, `notHeld` when it is not held (yet), `deleted` once it is gone. * `installation`: this computer, which the Admin lets take it only if it is the one the Cloud's things come home to. */ async exportHome({ installation } = {}) { const current = grant; if (!current) throw new Error("Sign in to MausBot Cloud first."); let result; try { result = await request("export", { method: "POST", body: installation ? { installation } : {}, token: current.token }); } catch (error) { if (error?.status === 409) return { notHeld: true }; if (error?.status === 410) return { deleted: true }; throw error; } if (result?.cloudContractVersion === 1 && result.state === "starting") return { starting: true }; const pairing = typeof result?.origin === "string" && result.origin.startsWith("https://") ? parsePairingGrant(result, result.origin, now()) : null; if (!pairing) throw new Error("Invalid Cloud export response."); return { grant: pairing }; }, /** Tells the Admin that this computer's move to My Cloud is done, as `installation` (POST /api/cloud/desktop/moved): * only such a computer can be the one an ended Cloud's things come home to. False when the Admin can't take it yet. */ async reportMove({ installation }) { const current = grant; if (!current) return false; try { await request("moved", { method: "POST", body: { installation }, token: current.token }); return true; } catch (error) { if (error?.status === 404 && !error.opaque) return false; throw error; } }, /** Tells the Admin that this computer, as `installation`, keeps its bots on itself again (POST * /api/cloud/desktop/kept): it no longer counts as moved, so an ended Cloud's things never come home to it. False when * the Admin can't take it yet. */ async reportKept({ installation }) { const current = grant; if (!current) return false; try { await request("kept", { method: "POST", body: { installation }, token: current.token }); return true; } catch (error) { if (error?.status === 404 && !error.opaque) return false; throw error; } }, /** The Admin's word on this account's own Cloud at `origin` (POST /api/cloud/desktop/moved-to), for a computer frozen * for a Cloud that is not My Cloud now: "deleted", "ended" (stopped and kept only until it is deleted), "live", or * "unknown" (none of this account's ever was). Null without a verified sign-in, or from an Admin that doesn't say (404). */ async movedTo(cloudOrigin) { const current = grant; if (!current || value.status !== "connected" || typeof cloudOrigin !== "string") return null; let result; try { result = await request("moved-to", { method: "POST", body: { origin: cloudOrigin }, token: current.token }); } catch (error) { if (error?.status === 404 && !error.opaque) return null; throw error; } return grant === current && result?.cloudContractVersion === 1 && MOVED_TO.includes(result.state) ? result.state : null; }, /** Tells the Admin where this computer's move to My Cloud stands (POST /api/cloud/desktop/move-status; move-status.mjs * builds `report`). False when there is no sign-in or the Admin doesn't take it yet (404). */ async reportMoveStatus(report) { const current = grant; if (!current) return false; try { await request("move-status", { method: "POST", body: report, token: current.token, timeoutMs: 15_000 }); return true; } catch (error) { if (error?.status === 404 && !error.opaque) return false; throw error; } }, /** This sign-in's own X research relay access while its plan is paid: * `{ url, token }` for the local server (electron/included-x.mjs), or null * when there is no sign-in, the Admin says the plan is not active (402) or * it does not offer X research (404). Never kept on disk. The relay must * be on the Cloud's own origin: the token goes nowhere else. */ async xResearchAccess() { const current = grant; if (!current) return null; let result; try { result = await request("services/x", { method: "POST", body: {}, token: current.token }); } catch (error) { if (error?.status === 402 || error?.status === 404) return null; throw error; } let relay = null; try { relay = typeof result?.url === "string" ? new URL(result.url) : null; } catch { relay = null; } if (result?.cloudContractVersion !== 1 || !relay || relay.origin !== origin || typeof result.token !== "string" || !/^omb_xd_[A-Za-z0-9_-]{43}$/.test(result.token)) { throw new Error("Invalid Cloud X research response."); } return { url: result.url, token: result.token }; }, /** This sign-in's own pages token, for any plan: `{ url, token }` for the local server (electron/included-pages.mjs), * or null when there is no sign-in or the Admin does not offer pages yet (404). Never kept on disk. The token route * must be on the Cloud's own origin: the token goes nowhere else. */ async pagesAccess() { const current = grant; if (!current) return null; let result; try { result = await request("services/pages", { method: "POST", body: {}, token: current.token }); } catch (error) { if (error?.status === 404) return null; throw error; } let target = null; try { target = typeof result?.url === "string" ? new URL(result.url) : null; } catch { target = null; } if (result?.cloudContractVersion !== 1 || !target || target.origin !== origin || typeof result.token !== "string" || !/^omb_pd_[A-Za-z0-9_-]{43}$/.test(result.token)) { throw new Error("Invalid Cloud pages response."); } return { url: result.url, token: result.token }; }, /** The account's answer to the product-updates question (GET /api/cloud/desktop/updates-consent), as the Admin sends it. */ async updatesConsent() { const current = grant; if (!current || state().status !== "connected") throw new Error("Sign in to MausBot Cloud first."); return request("updates-consent", { token: current.token, signal: AbortSignal.timeout(10_000) }); }, async recordUpdatesConsent(status, textVersion) { const current = grant; if (!current || state().status !== "connected") throw new Error("Sign in to MausBot Cloud first."); const result = await request("updates-consent", { method: "POST", body: { status, textVersion }, token: current.token, signal: AbortSignal.timeout(10_000) }); if (result?.cloudContractVersion !== 1 || result.consent?.status !== status) throw new Error("Invalid Cloud consent response."); }, /** No sign-in: an email typed in the app (POST /api/updates-consent). Returns the receipt that can withdraw it. */ async updatesConsentByEmail({ email, installId, textVersion }) { const result = await request(null, { method: "POST", path: "/api/updates-consent", body: { status: "opted_in", email, installId, textVersion }, signal: AbortSignal.timeout(10_000) }); if (typeof result?.receipt !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(result.receipt)) throw new Error("Invalid Cloud consent response."); return result.receipt; }, async withdrawUpdatesConsent({ status, receipt }) { const result = await request(null, { method: "POST", path: "/api/updates-consent", body: { status, receipt }, signal: AbortSignal.timeout(10_000) }); if (result?.ok !== true) throw new Error("Invalid Cloud consent response."); }, /** Another account takes over: nothing more is asked or written for this sign-in. */ quiesce() { quiet = true; reset(); }, /** Reads the sign-in saved in `store` afresh: nothing of the one before stays. Told once, when the Admin has answered. */ reload() { quiet = false; reset(); grant = null; issued = null; cleanup = null; cleanupNeeded = false; plan = null; savedHint = null; opened = null; adding = null; renewalCloud = null; offerCache = null; waking = false; confirmations.clear(); value = { status: "signed-out", message: "restoring" }; return start(); }, /** Ends a sign-in that waits on this device, at the Admin. */ revokeSaved: revoke, close() { closed = true; reset(); }, }; } export function cloudMenuSublabel(state, date, t = englishText.t) { if (state?.status !== "connected") return null; const trial = state.trial; if (state.entitlement?.plan === "pro" && state.entitlement.status === "active") { if (trial?.state === "active" || trial?.state === "ending") return t("native.cloud.sub.trialUntil", { date: date(trial.endsAt) }); return t(state.machine?.power?.sleeps ? "native.cloud.sub.sleeps" : "native.cloud.sub.alwaysOn"); } return state.machine || trial ? t("native.cloud.sub.stopped") : null; } /** What the person's own Cloud page may show of the plan: never the account, a credential or an address. */ export function cloudPlanSnapshot(state, { notice = null } = {}) { const tier = state?.status === "connected" ? state.entitlement?.tier : state?.lastPlan?.tier; const named = typeof tier === "string" ? { tier } : {}; const trial = state?.status === "connected" ? { ...(state.trial ? { trial: state.trial } : {}), ...(state.credit ? { credit: state.credit } : {}), ...(notice && state.trial ? { notice } : {}), ...(state.machine?.power?.sleeps ? { sleeps: true } : {}), ...(state.usage ? { usage: state.usage } : {}), ...(state.room ? { room: state.room } : {}), ...(state.renewal ? { renewal: state.renewal } : {}) } : {}; if (state?.status === "connected" && state.entitlement?.plan === "pro") return { status: state.entitlement.status === "active" ? "paid" : "attention", ...named, ...trial }; if (state?.status === "reauth-required") return { status: "signin", ...(state.lastPlan ? { tier: state.lastPlan.tier ?? "pro" } : {}) }; if (state?.status !== "connected" && state?.lastPlan) return { status: "checking", ...named }; return { status: "none", ...trial }; }