From f3c1d09521f71d7567435b90a21786521def5648 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Fri, 17 Jul 2026 09:44:12 +0000
Subject: [PATCH] promote: daily cookie-session keep-alive (the "refresh every
24h" for cookie auth)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Cookie-auth accounts have no OAuth refresh token to exchange, so a literal
token-refresh job has nothing to call. The cookie equivalent: once a day reload
each active cookie account with its stored cookies and re-save the rotated
(sliding-expiry) cookies the site issues, which extends the session and staves
off premature token_expired. Also proactively flags a session that HAS died so
the user is prompted to reconnect before a scheduled post fails.
- migration: sp_account.session_refreshed_at (gates the 24h cadence across
worker restarts + surfaces "last kept alive" in the UI)
- lib/sp/platforms/browser.ts: refreshCookieSession() — loads the home URL,
checks login, returns the context's refreshed cookies
- lib/sp/sessionRefresh.ts: refreshCookieSessions() sweep — per-platform home
URLs, re-encrypts refreshed cookies, flags dead sessions token_expired;
never flags on a transient/navigation error
- worker/index.ts: run at startup + every 24h (23h per-account gate)
- promote/accounts: shows "Session kept alive
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));
});