Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion app/(app)/promote/accounts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> = {
Expand All @@ -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 });
Expand Down Expand Up @@ -98,6 +99,11 @@ export default async function PromoteAccountsPage() {
Last post {new Date(a.last_post_at).toLocaleString()}
</p>
)}
{a.status === "active" && a.session_refreshed_at && (
<p className="mt-1 text-xs text-[var(--color-muted)]">
Session kept alive {new Date(a.session_refreshed_at).toLocaleString()}
</p>
)}
{a.status !== "active" && PLATFORM_LOGIN_URLS[a.platform] && (
<p className="mt-1 text-xs text-[var(--color-warn)]">
Session expired.{" "}
Expand Down
39 changes: 39 additions & 0 deletions lib/sp/platforms/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
108 changes: 108 additions & 0 deletions lib/sp/sessionRefresh.ts
Original file line number Diff line number Diff line change
@@ -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<any>,
opts: { maxAgeHours?: number } = {},
): Promise<SessionRefreshResult> {
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;
}
Original file line number Diff line number Diff line change
@@ -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;
21 changes: 21 additions & 0 deletions worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!;
Expand Down Expand Up @@ -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.
Expand All @@ -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));
});
Loading