diff --git a/app/(app)/promote/accounts/page.tsx b/app/(app)/promote/accounts/page.tsx index 63975d7e..a8a76a74 100644 --- a/app/(app)/promote/accounts/page.tsx +++ b/app/(app)/promote/accounts/page.tsx @@ -18,6 +18,7 @@ type AccountRow = { status: string; last_post_at: string | null; consecutive_failures: number; + session_refreshed_at: string | null; }; const PLATFORM_LOGIN_URLS: Record = { @@ -39,7 +40,7 @@ export default async function PromoteAccountsPage() { const { data: accounts } = await supabase .from("sp_account") .select( - "id, platform, handle, status, last_post_at, consecutive_failures", + "id, platform, handle, status, last_post_at, consecutive_failures, session_refreshed_at", ) .eq("user_id", user.id) .order("created_at", { ascending: false }); @@ -98,6 +99,11 @@ export default async function PromoteAccountsPage() { Last post {new Date(a.last_post_at).toLocaleString()}

)} + {a.status === "active" && a.session_refreshed_at && ( +

+ Session kept alive {new Date(a.session_refreshed_at).toLocaleString()} +

+ )} {a.status !== "active" && PLATFORM_LOGIN_URLS[a.platform] && (

Session expired.{" "} diff --git a/lib/sp/platforms/browser.ts b/lib/sp/platforms/browser.ts index 7755f366..058ae43f 100644 --- a/lib/sp/platforms/browser.ts +++ b/lib/sp/platforms/browser.ts @@ -185,6 +185,45 @@ export function parseCookies(raw: string): BrowserCookie[] { }); } +// Keep a cookie session warm: load the platform home with the current cookies +// and read back the cookies the site issues on that visit — many sites use +// sliding-expiry session cookies that get extended on activity, so re-saving +// them lengthens the session. Also reports whether we're still logged in, so a +// dead session can be flagged proactively (before a scheduled post fails). +// Cannot revive an already-dead session — a logged-out visit just returns +// loggedIn:false. +export async function refreshCookieSession(args: { + platform: string; + homeUrl: string; + cookies: BrowserCookie[]; +}): Promise<{ loggedIn: boolean; cookies: BrowserCookie[] }> { + const { browser, ctx } = await launchContext(args.cookies); + const page = await ctx.newPage(); + try { + await page.goto(args.homeUrl, { waitUntil: "domcontentloaded" }); + let loggedIn = true; + try { + await assertLoggedIn(page, args.platform); + } catch { + loggedIn = false; + } + // Playwright's Cookie shape matches BrowserCookie; keep the refreshed set. + const fresh: BrowserCookie[] = (await ctx.cookies()).map((c) => ({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + expires: c.expires, + httpOnly: c.httpOnly, + secure: c.secure, + sameSite: c.sameSite as BrowserCookie["sameSite"], + })); + return { loggedIn, cookies: fresh }; + } finally { + await browser.close(); + } +} + // ---------- Reddit ---------- export async function redditBrowserPost(args: { diff --git a/lib/sp/sessionRefresh.ts b/lib/sp/sessionRefresh.ts new file mode 100644 index 00000000..15f588fc --- /dev/null +++ b/lib/sp/sessionRefresh.ts @@ -0,0 +1,108 @@ +// Daily keep-alive for cookie-auth social accounts. +// +// Cookie sessions aren't OAuth tokens — there's no refresh token to exchange. +// But most sites issue sliding-expiry session cookies that get extended on +// activity, so reloading the site once a day with the current cookies and +// re-saving whatever the site hands back lengthens the session and staves off +// premature "token_expired". It also detects a session that HAS died and flags +// the account proactively, so the user is prompted to reconnect before a +// scheduled Promote post fails. It cannot revive an already-dead session. + +import type { SupabaseClient } from "@supabase/supabase-js"; +import { encryptSecret, decryptSecret } from "@/lib/sp/vault"; +import { parseCookies, refreshCookieSession } from "@/lib/sp/platforms/browser"; + +// The URL to load to keep each platform's cookie session warm. +function homeUrl(platform: string, instanceUrl: string | null): string | null { + switch (platform) { + case "reddit": + return "https://www.reddit.com"; + case "facebook_page": + return "https://www.facebook.com"; + case "threads": + return "https://www.threads.com"; + case "instagram": + return "https://www.instagram.com"; + case "linkedin": + return "https://www.linkedin.com/feed/"; + case "x": + return "https://x.com/home"; + case "mastodon": { + const host = (instanceUrl ?? "").replace(/^https?:\/\//, "").replace(/\/+$/, ""); + return host ? `https://${host}` : "https://mastodon.social"; + } + default: + return null; + } +} + +type Row = { + id: string; + platform: string; + instance_url: string | null; + enc_access_token: string | null; +}; + +export type SessionRefreshResult = { + checked: number; + refreshed: number; + expired: number; + skipped: number; +}; + +export async function refreshCookieSessions( + supabase: SupabaseClient, + opts: { maxAgeHours?: number } = {}, +): Promise { + const maxAge = opts.maxAgeHours ?? 23; + const cutoff = new Date(Date.now() - maxAge * 3_600_000).toISOString(); + const out: SessionRefreshResult = { checked: 0, refreshed: 0, expired: 0, skipped: 0 }; + + // Active cookie accounts not refreshed within the window (the gate keeps + // worker restarts from re-warming a session we just warmed). + const { data } = await supabase + .from("sp_account") + .select("id, platform, instance_url, enc_access_token") + .eq("auth_mode", "cookie") + .eq("status", "active") + .or(`session_refreshed_at.is.null,session_refreshed_at.lt.${cutoff}`); + const rows = (data as Row[]) ?? []; + + for (const acct of rows) { + const url = homeUrl(acct.platform, acct.instance_url); + if (!url || !acct.enc_access_token) { + out.skipped++; + continue; + } + out.checked++; + try { + const cookies = parseCookies(decryptSecret(acct.enc_access_token)); + const res = await refreshCookieSession({ platform: acct.platform, homeUrl: url, cookies }); + if (!res.loggedIn) { + // Session is dead — flag it so the UI prompts a reconnect (and posting + // stops until fresh cookies are exported). + await supabase.from("sp_account").update({ status: "token_expired" }).eq("id", acct.id); + out.expired++; + continue; + } + await supabase + .from("sp_account") + .update({ + enc_access_token: encryptSecret(JSON.stringify(res.cookies)), + session_refreshed_at: new Date().toISOString(), + }) + .eq("id", acct.id); + out.refreshed++; + } catch (err) { + // Transient failure (navigation error / temporary block) — leave the + // account untouched and retry next run. Never flag token_expired on a + // non-login error, or a blip would nuke a live session. + console.warn( + `[session-refresh] ${acct.platform} ${acct.id} failed:`, + err instanceof Error ? err.message : err, + ); + out.skipped++; + } + } + return out; +} diff --git a/supabase/migrations/20260717160000_sp_account_session_refreshed.sql b/supabase/migrations/20260717160000_sp_account_session_refreshed.sql new file mode 100644 index 00000000..5b127076 --- /dev/null +++ b/supabase/migrations/20260717160000_sp_account_session_refreshed.sql @@ -0,0 +1,10 @@ +-- Track when a cookie-auth account's browser session was last kept warm. +-- The worker's daily session-refresh sweep reloads each active cookie account +-- with its stored cookies and re-saves the rotated (sliding-expiry) cookies to +-- extend the session; this timestamp both gates the 24h cadence (so worker +-- restarts don't re-refresh a session that was just refreshed) and surfaces +-- "last refreshed" in the UI. +-- +-- Apply via psql over the pooler / MCP (prod migration history diverged). +alter table public.sp_account + add column if not exists session_refreshed_at timestamptz; diff --git a/worker/index.ts b/worker/index.ts index bfff7ef0..6a1b4c3a 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -39,6 +39,7 @@ import { processUserAlerts } from "../lib/alerts/worker"; import { processDuePortScans } from "../lib/prober-queue"; import { processDueMonitors } from "../lib/uptime"; import { processDuePromoteLists } from "../lib/promote/sweep"; +import { refreshCookieSessions } from "../lib/sp/sessionRefresh"; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; @@ -1318,6 +1319,25 @@ setInterval( 15_000, ); +// Session keep-alive: once a day, reload each cookie-auth social account with +// its stored cookies and re-save the rotated (sliding-expiry) cookies to extend +// the session; flag dead sessions token_expired so the user reconnects before a +// post fails. The 23h gate inside the sweep keeps worker restarts from +// re-warming a session that was just warmed. +const DAY_MS = 24 * 60 * 60 * 1000; +async function sessionRefreshSweep() { + const r = await refreshCookieSessions(supabase); + if (r.checked > 0) { + console.log( + `[worker] session refresh checked=${r.checked} refreshed=${r.refreshed} expired=${r.expired} skipped=${r.skipped}`, + ); + } +} +setInterval( + () => sessionRefreshSweep().catch((e) => console.error("[worker] session refresh sweep", e)), + DAY_MS, +); + // Bind to loopback by default so the worker isn't reachable from the public // internet when colocated with the app. Override with WORKER_BIND=0.0.0.0 to // run as a separate Railway service. @@ -1327,4 +1347,5 @@ server.listen(port, bindHost, () => { sweep().catch(() => {}); socialFeedSweep().catch((e) => console.error("[worker] social feed sweep", e)); promoteSweep().catch((e) => console.error("[worker] promote sweep", e)); + sessionRefreshSweep().catch((e) => console.error("[worker] session refresh sweep", e)); });