diff --git a/app/(app)/projects/[id]/social/page.tsx b/app/(app)/projects/[id]/social/page.tsx
index 65999e1b..e046fc97 100644
--- a/app/(app)/projects/[id]/social/page.tsx
+++ b/app/(app)/projects/[id]/social/page.tsx
@@ -4,6 +4,7 @@ import { createClient } from "@/lib/supabase/server";
import { PostNowForm } from "./post-now";
import { RetryPostButton } from "./retry-post-button";
import { RetryFeedItemButton } from "./retry-feed-item-button";
+import { VerificationCodeInput } from "@/components/verification-code-input";
import { FeedSettingsForm } from "./feed-settings";
import { SocialAutoRefresh } from "./auto-refresh";
import { SocialProfileForm, type SocialProfile } from "./social-profile";
@@ -79,7 +80,7 @@ export default async function SocialDashboardPage({
supabase
.from("sp_post")
.select(
- "id, account_id, rendered_text, source, status, published_at, platform_post_url, last_error, created_at",
+ "id, account_id, rendered_text, source, status, published_at, platform_post_url, last_error, created_at, verification_prompt",
)
.eq("user_id", user.id)
.eq("project_id", projectId)
@@ -141,7 +142,10 @@ export default async function SocialDashboardPage({
// Only auto-refresh while something is actively moving. Idle tabs
// shouldn't be hitting the DB every 15s.
const inFlightPosts = (posts ?? []).filter(
- (p: any) => p.status === "queued" || p.status === "publishing",
+ (p: any) =>
+ p.status === "queued" ||
+ p.status === "publishing" ||
+ p.status === "awaiting_code",
);
const hasInFlightPost = inFlightPosts.length > 0;
const feedChecking =
@@ -484,7 +488,7 @@ export default async function SocialDashboardPage({
: "badge-warn")
}
>
- {p.status}
+ {p.status === "awaiting_code" ? "needs code" : p.status}
{url && title && (
@@ -531,6 +535,14 @@ export default async function SocialDashboardPage({
>
)}
+ {p.status === "awaiting_code" && (
+
+
+
+ )}
{p.status === "failed" && (
{p.last_error && (
diff --git a/app/(marketing)/recent/outreach-form.tsx b/app/(marketing)/recent/outreach-form.tsx
index 4c2c70d0..8565e572 100644
--- a/app/(marketing)/recent/outreach-form.tsx
+++ b/app/(marketing)/recent/outreach-form.tsx
@@ -8,16 +8,19 @@ import {
sendRecentAuditOutreach,
} from "@/app/actions/recent-outreach";
import { OUTREACH_CREDITS } from "@/lib/credits";
+import { VerificationCodeInput } from "@/components/verification-code-input";
export type OutreachHistoryItem = {
id: string;
channel: string;
provider: string;
- status: "sent" | "failed" | "queued" | "timed_out";
+ status: "sent" | "failed" | "queued" | "timed_out" | "awaiting_code";
subject: string | null;
error: string | null;
createdAt: string;
url: string | null;
+ verificationPostId?: string | null;
+ verificationPrompt?: string | null;
};
export function RecentOutreachForm({
@@ -132,6 +135,14 @@ export function RecentOutreachForm({
{(h.status === "failed" || h.status === "timed_out") && (
)}
+ {h.status === "awaiting_code" && h.verificationPostId && (
+
+
+
+ )}
{(h.status === "failed" || h.status === "timed_out") && h.error && (
{h.error}
@@ -325,7 +336,9 @@ function defaultBody(host: string) {
}
function statusLabel(status: OutreachHistoryItem["status"]) {
- return status === "timed_out" ? "timed out" : status;
+ if (status === "timed_out") return "timed out";
+ if (status === "awaiting_code") return "needs code";
+ return status;
}
function statusClass(status: OutreachHistoryItem["status"]) {
@@ -333,5 +346,6 @@ function statusClass(status: OutreachHistoryItem["status"]) {
if (status === "sent") return `${base} bg-green-100 text-green-800`;
if (status === "failed") return `${base} bg-red-100 text-red-700`;
if (status === "timed_out") return `${base} bg-amber-100 text-amber-800`;
+ if (status === "awaiting_code") return `${base} bg-blue-100 text-blue-800`;
return `${base} bg-[var(--color-border)] text-[var(--color-muted)]`;
}
diff --git a/app/(marketing)/recent/page.tsx b/app/(marketing)/recent/page.tsx
index a895cc08..1b81db5b 100644
--- a/app/(marketing)/recent/page.tsx
+++ b/app/(marketing)/recent/page.tsx
@@ -321,7 +321,7 @@ async function fetchOutreachHistory(
const { data } = await supabase
.from("recent_outreach_messages")
.select(
- "id, audit_id, channel, provider, status, subject, error, created_at, social_post:sp_post(status, platform_post_url, last_error)",
+ "id, audit_id, channel, provider, status, subject, error, created_at, social_post:sp_post(id, status, platform_post_url, last_error, verification_prompt)",
)
.eq("organization_id", organizationId)
.in("audit_id", auditIds)
@@ -354,6 +354,10 @@ async function fetchOutreachHistory(
error: derived.error,
createdAt: row.created_at,
url: post?.platform_post_url ?? null,
+ verificationPostId:
+ derived.status === "awaiting_code" ? post?.id ?? null : null,
+ verificationPrompt:
+ derived.status === "awaiting_code" ? post?.verification_prompt ?? null : null,
};
const list = byAudit.get(row.audit_id) ?? [];
list.push(item);
diff --git a/app/actions/socialPosting.ts b/app/actions/socialPosting.ts
index 81bced2b..e34c7e1b 100644
--- a/app/actions/socialPosting.ts
+++ b/app/actions/socialPosting.ts
@@ -918,3 +918,50 @@ export async function retryFeedItem(input: {
if (projectId) revalidatePath(`/projects/${projectId}/social`);
return { ok: true };
}
+
+// ------------------------------------------------------------
+// submitVerificationCode — hand a verification code to a browser post that is
+// paused on an identity challenge (status 'awaiting_code'). The worker is
+// holding the live Chromium session open and polling sp_post.verification_code;
+// writing it here lets it type the code and finish posting.
+// ------------------------------------------------------------
+export async function submitVerificationCode(input: {
+ postId: string;
+ code: string;
+}): Promise {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) return { ok: false, error: "Not authenticated." };
+
+ // Accept the common 4–8 digit codes; strip spaces/dashes the user may paste.
+ const code = input.code.replace(/[\s-]/g, "");
+ if (!/^\d{4,8}$/.test(code)) {
+ return { ok: false, error: "Enter the numeric code (4–8 digits)." };
+ }
+
+ const { data: post } = await supabase
+ .from("sp_post")
+ .select("id, status, project_id")
+ .eq("id", input.postId)
+ .eq("user_id", user.id)
+ .maybeSingle();
+ if (!post) return { ok: false, error: "Post not found." };
+ if ((post as { status?: string }).status !== "awaiting_code") {
+ return { ok: false, error: "This post isn't waiting for a code right now." };
+ }
+
+ const { error } = await supabase
+ .from("sp_post")
+ .update({ verification_code: code })
+ .eq("id", input.postId)
+ .eq("user_id", user.id)
+ .eq("status", "awaiting_code");
+ if (error) return { ok: false, error: error.message };
+
+ const projectId = (post as { project_id?: string | null }).project_id;
+ if (projectId) revalidatePath(`/projects/${projectId}/social`);
+ revalidatePath("/recent");
+ return { ok: true };
+}
diff --git a/components/verification-code-input.tsx b/components/verification-code-input.tsx
new file mode 100644
index 00000000..998c4f0d
--- /dev/null
+++ b/components/verification-code-input.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { submitVerificationCode } from "@/app/actions/socialPosting";
+
+// Shown on a browser post that's paused on an identity challenge
+// (status 'awaiting_code'). The worker is holding the live session open and
+// polling for the code; submitting it here lets the post finish.
+export function VerificationCodeInput({
+ postId,
+ prompt,
+}: {
+ postId: string;
+ prompt?: string | null;
+}) {
+ const router = useRouter();
+ const [code, setCode] = useState("");
+ const [pending, start] = useTransition();
+ const [err, setErr] = useState(null);
+ const [done, setDone] = useState(false);
+
+ if (done) {
+ return (
+
+ Code submitted — finishing the post…
+
+ );
+ }
+
+ return (
+
+ {prompt && (
+ {prompt}
+ )}
+ setCode(e.target.value)}
+ inputMode="numeric"
+ autoComplete="one-time-code"
+ placeholder="123456"
+ className="w-24 rounded border border-[var(--color-border)] bg-[var(--color-card)] px-2 py-0.5 tabular-nums"
+ />
+
+ {err && (
+
+ {err}
+
+ )}
+
+ );
+}
diff --git a/lib/sp/browserPost.ts b/lib/sp/browserPost.ts
index dc2c0d65..5c45bc50 100644
--- a/lib/sp/browserPost.ts
+++ b/lib/sp/browserPost.ts
@@ -31,6 +31,7 @@ import {
} from "@/lib/sp/imageGen";
import { reconcileOutreach } from "@/lib/sp/outreachReconcile";
import { pickDefaultSubreddit } from "@/lib/sp/redditSubreddit";
+import { makeCodeWaiter } from "@/lib/sp/verificationChallenge";
export async function processBrowserPost(args: {
postId: string;
@@ -108,6 +109,11 @@ export async function processBrowserPost(args: {
return;
}
+ // When a platform interrupts the session for a verification code, the
+ // platform flow calls this to pause, surface a prompt, and wait for the code
+ // the user submits (see makeCodeWaiter / submitVerificationCode).
+ const waitForCode = makeCodeWaiter(supabase, postId);
+
try {
let result: { platformPostId: string; webUrl: string };
@@ -118,29 +124,31 @@ export async function processBrowserPost(args: {
const subreddit =
((claimed.subreddit as string | null) ?? "").trim() ||
pickDefaultSubreddit(title || text);
- result = await redditBrowserPost({ cookies, subreddit, title, text });
+ result = await redditBrowserPost({ cookies, subreddit, title, text, waitForCode });
} else if (account.platform === "facebook_page") {
result = await facebookBrowserPost({
cookies,
pageId: account.external_id,
text,
imageUrl,
+ waitForCode,
});
} else if (account.platform === "threads") {
- result = await threadsBrowserPost({ cookies, text, imageUrl });
+ result = await threadsBrowserPost({ cookies, text, imageUrl, waitForCode });
} else if (account.platform === "instagram") {
result = await instagramBrowserPost({
cookies,
caption: text,
imageUrl: imageUrl!,
+ waitForCode,
});
} else if (account.platform === "x") {
- result = await xBrowserPost({ cookies, text, imageUrl });
+ result = await xBrowserPost({ cookies, text, imageUrl, waitForCode });
} else if (account.platform === "linkedin") {
- result = await linkedinBrowserPost({ cookies, text, imageUrl });
+ result = await linkedinBrowserPost({ cookies, text, imageUrl, waitForCode });
} else if (account.platform === "mastodon") {
const instanceUrl = account.instance_url ?? "mastodon.social";
- result = await mastodonBrowserPost({ cookies, instanceUrl, text, imageUrl });
+ result = await mastodonBrowserPost({ cookies, instanceUrl, text, imageUrl, waitForCode });
} else {
throw new Error(`Browser posting not implemented for platform: ${account.platform}`);
}
diff --git a/lib/sp/outreachStatus.ts b/lib/sp/outreachStatus.ts
index 1a094cd3..6d3937d1 100644
--- a/lib/sp/outreachStatus.ts
+++ b/lib/sp/outreachStatus.ts
@@ -8,12 +8,19 @@
// never came back.
export type OutreachRowStatus = "sent" | "failed" | "queued";
-export type OutreachDisplayStatus = "sent" | "failed" | "queued" | "timed_out";
+export type OutreachDisplayStatus =
+ | "sent"
+ | "failed"
+ | "queued"
+ | "timed_out"
+ | "awaiting_code";
export type OutreachSocialPost = {
+ id?: string | null;
status: string | null;
platform_post_url: string | null;
last_error: string | null;
+ verification_prompt?: string | null;
};
// How long a browser-automated post (auth_mode='cookie') may sit in the
@@ -37,6 +44,14 @@ export function deriveOutreachStatus(
if (post.status === "failed" || post.status === "cancelled") {
return { status: "failed", error: post.last_error ?? rowError };
}
+ // Paused on an identity challenge — the user needs to enter a code. Surface
+ // it before the stale check so it isn't mistaken for a hung job.
+ if (post.status === "awaiting_code") {
+ return {
+ status: "awaiting_code",
+ error: post.verification_prompt ?? "Waiting for a verification code.",
+ };
+ }
// Still queued_browser / publishing — flip to timed out once stale.
if (now - new Date(createdAt).getTime() > OUTREACH_STALE_MS) {
return {
diff --git a/lib/sp/platforms/browser.ts b/lib/sp/platforms/browser.ts
index eb3914ff..c732b376 100644
--- a/lib/sp/platforms/browser.ts
+++ b/lib/sp/platforms/browser.ts
@@ -16,6 +16,13 @@
import { chromium, type Browser, type BrowserContext } from "playwright";
import { browserSemaphore } from "@/lib/sp/browserSemaphore";
+import { handleCodeChallenge, type CodeWaiter } from "@/lib/sp/verificationChallenge";
+
+// Standard challenge prompt shown to the user when a platform interrupts the
+// session asking for a verification code.
+function codePrompt(platform: string): string {
+ return `${platform} is asking for a verification code. Enter the code it just sent (email / SMS / authenticator) to finish posting.`;
+}
export type BrowserCookie = {
name: string;
@@ -115,8 +122,9 @@ export async function redditBrowserPost(args: {
subreddit: string;
title: string;
text: string;
+ waitForCode?: CodeWaiter;
}): Promise {
- const { cookies, subreddit, title, text } = args;
+ const { cookies, subreddit, title, text, waitForCode } = args;
const sr = subreddit.replace(/^\/?r\//, "");
const { browser, ctx } = await launchContext(cookies);
try {
@@ -124,6 +132,7 @@ export async function redditBrowserPost(args: {
await page.goto(`https://www.reddit.com/r/${sr}/submit`, {
waitUntil: "domcontentloaded",
});
+ if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Reddit"));
// Select "Text" tab
const textTab = page.getByRole("tab", { name: /text/i });
@@ -164,14 +173,16 @@ export async function facebookBrowserPost(args: {
pageId: string;
text: string;
imageUrl?: string;
+ waitForCode?: CodeWaiter;
}): Promise {
- const { cookies, pageId, text, imageUrl } = args;
+ const { cookies, pageId, text, imageUrl, waitForCode } = args;
const { browser, ctx } = await launchContext(cookies);
try {
const page = await ctx.newPage();
await page.goto(`https://www.facebook.com/${pageId}`, {
waitUntil: "domcontentloaded",
});
+ if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Facebook"));
// Click the "Write something..." composer
const composer = page.getByPlaceholder(/write something/i)
@@ -227,12 +238,14 @@ export async function threadsBrowserPost(args: {
cookies: BrowserCookie[];
text: string;
imageUrl?: string;
+ waitForCode?: CodeWaiter;
}): Promise {
- const { cookies, text, imageUrl } = args;
+ const { cookies, text, imageUrl, waitForCode } = args;
const { browser, ctx } = await launchContext(cookies);
try {
const page = await ctx.newPage();
await page.goto("https://www.threads.net", { waitUntil: "domcontentloaded" });
+ if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Threads"));
// New Thread button
const newThreadBtn = page
@@ -287,12 +300,14 @@ export async function instagramBrowserPost(args: {
cookies: BrowserCookie[];
caption: string;
imageUrl: string; // required — Instagram does not support text-only posts
+ waitForCode?: CodeWaiter;
}): Promise {
- const { cookies, caption, imageUrl } = args;
+ const { cookies, caption, imageUrl, waitForCode } = args;
const { browser, ctx } = await launchContext(cookies);
try {
const page = await ctx.newPage();
await page.goto("https://www.instagram.com", { waitUntil: "domcontentloaded" });
+ if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Instagram"));
// Download image to temp file
const imgRes = await fetch(imageUrl);
@@ -354,12 +369,14 @@ export async function xBrowserPost(args: {
cookies: BrowserCookie[];
text: string;
imageUrl?: string;
+ waitForCode?: CodeWaiter;
}): Promise {
- const { cookies, text, imageUrl } = args;
+ const { cookies, text, imageUrl, waitForCode } = args;
const { browser, ctx } = await launchContext(cookies);
try {
const page = await ctx.newPage();
await page.goto("https://x.com/home", { waitUntil: "domcontentloaded" });
+ if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("X"));
// Click the compose box
const compose = page
@@ -410,12 +427,14 @@ export async function linkedinBrowserPost(args: {
cookies: BrowserCookie[];
text: string;
imageUrl?: string;
+ waitForCode?: CodeWaiter;
}): Promise {
- const { cookies, text, imageUrl } = args;
+ const { cookies, text, imageUrl, waitForCode } = args;
const { browser, ctx } = await launchContext(cookies);
try {
const page = await ctx.newPage();
await page.goto("https://www.linkedin.com/feed/", { waitUntil: "domcontentloaded" });
+ if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("LinkedIn"));
// Start a post
const startPost = page
@@ -476,19 +495,24 @@ export async function mastodonBrowserPost(args: {
instanceUrl: string;
text: string;
imageUrl?: string;
+ waitForCode?: CodeWaiter;
}): Promise {
- const { cookies, instanceUrl, text, imageUrl } = args;
+ const { cookies, instanceUrl, text, imageUrl, waitForCode } = args;
const base = instanceUrl.startsWith("http") ? instanceUrl : `https://${instanceUrl}`;
const { browser, ctx } = await launchContext(cookies);
try {
const page = await ctx.newPage();
await page.goto(base, { waitUntil: "domcontentloaded" });
+ if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Mastodon"));
- // Mastodon web app — compose textarea
+ // Mastodon web app — compose textarea. Match the compose box specifically:
+ // a bare getByRole("textbox") also matched the "Search or paste URL" input
+ // and tripped strict-mode. The compose box's aria-label is "What's on your
+ // mind?" and it carries the autosuggest-textarea__textarea class.
const compose = page
- .locator('textarea.autosuggest-textarea__textarea')
- .or(page.locator('[placeholder*="what" i]').first())
- .or(page.getByRole("textbox").first());
+ .locator("textarea.autosuggest-textarea__textarea")
+ .or(page.getByRole("textbox", { name: /what.?s on your mind/i }))
+ .first();
await compose.waitFor({ timeout: 10_000 });
await compose.fill(text);
diff --git a/lib/sp/verificationChallenge.ts b/lib/sp/verificationChallenge.ts
new file mode 100644
index 00000000..9acea4b6
--- /dev/null
+++ b/lib/sp/verificationChallenge.ts
@@ -0,0 +1,132 @@
+// Human-in-the-loop verification-code (identity challenge) handling for
+// browser-automated posts.
+//
+// Some platforms (LinkedIn especially) interrupt a cookie session with an
+// "enter the 6-digit code we just emailed/texted you" challenge before showing
+// the composer. We keep the SAME Playwright session open, mark the post
+// 'awaiting_code' with a human-readable prompt, and poll sp_post for a code the
+// user submits in the UI. When it arrives we type it into the live page and
+// continue posting. If no code shows up within the timeout we give up so the
+// browser (and its concurrency slot) is freed.
+
+import type { Locator, Page } from "playwright";
+import type { SupabaseClient } from "@supabase/supabase-js";
+
+// A function a platform flow calls when it detects a challenge: it surfaces the
+// prompt to the user and resolves with the code they enter (or throws on
+// timeout).
+export type CodeWaiter = (prompt: string) => Promise;
+
+const DEFAULT_TIMEOUT_MS = 3 * 60 * 1000; // 3 min — a browser slot is held open.
+const DEFAULT_POLL_MS = 3 * 1000;
+
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+// Build a CodeWaiter bound to one post. Marks the post 'awaiting_code' (storing
+// the prompt so the UI can show what's being asked), then polls until the user
+// submits a code or we time out. On success it flips back to 'publishing' and
+// clears the code so it can't be replayed.
+export function makeCodeWaiter(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ supabase: SupabaseClient,
+ postId: string,
+ opts: { timeoutMs?: number; pollMs?: number } = {},
+): CodeWaiter {
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
+ const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
+
+ return async function waitForCode(prompt: string): Promise {
+ await supabase
+ .from("sp_post")
+ .update({
+ status: "awaiting_code",
+ verification_prompt: prompt,
+ verification_requested_at: new Date().toISOString(),
+ verification_code: null,
+ })
+ .eq("id", postId);
+
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ await sleep(pollMs);
+ const { data } = await supabase
+ .from("sp_post")
+ .select("verification_code")
+ .eq("id", postId)
+ .maybeSingle();
+ const code = ((data?.verification_code as string | null) ?? "").trim();
+ if (code) {
+ await supabase
+ .from("sp_post")
+ .update({
+ status: "publishing",
+ verification_code: null,
+ verification_prompt: null,
+ })
+ .eq("id", postId);
+ return code;
+ }
+ }
+
+ throw new Error(
+ "Timed out waiting for the verification code. Enter it sooner and retry.",
+ );
+ };
+}
+
+// Best-effort detector for a verification-code input on the current page.
+// Deliberately narrow (one-time-code / pin / otp / verification hints) to avoid
+// mistaking a normal text field for a challenge. Returns the input + a likely
+// submit control, or null when no challenge is visible.
+export async function detectCodeChallenge(
+ page: Page,
+ timeoutMs = 2500,
+): Promise<{ input: Locator; submit: Locator | null } | null> {
+ const input = page
+ .locator('input[autocomplete="one-time-code"]')
+ .or(page.locator('input[name*="pin" i]'))
+ .or(page.locator('input[id*="pin" i]'))
+ .or(page.locator('input[name*="otp" i]'))
+ .or(page.locator('input[name*="verification" i]'))
+ .or(page.locator('input[id*="verification" i]'))
+ .or(page.locator('input[aria-label*="verification code" i]'))
+ .or(page.locator('input[name="code" i]'))
+ .first();
+
+ const visible = await input
+ .waitFor({ state: "visible", timeout: timeoutMs })
+ .then(() => true)
+ .catch(() => false);
+ if (!visible) return null;
+
+ const submit = page
+ .getByRole("button", { name: /verify|submit|confirm|continue|next|done/i })
+ .first();
+ const hasSubmit = await submit.isVisible({ timeout: 1000 }).catch(() => false);
+ return { input, submit: hasSubmit ? submit : null };
+}
+
+// If a code challenge is on the page, resolve it end-to-end: ask the user (via
+// waitForCode), type the code, submit, and wait for the page to move on.
+// Returns true if a challenge was handled, false if none was present. Safe to
+// call at the top of every platform flow.
+export async function handleCodeChallenge(
+ page: Page,
+ waitForCode: CodeWaiter,
+ prompt: string,
+): Promise {
+ const challenge = await detectCodeChallenge(page);
+ if (!challenge) return false;
+
+ const code = await waitForCode(prompt);
+ await challenge.input.fill(code);
+ if (challenge.submit) {
+ await challenge.submit.click().catch(() => {});
+ } else {
+ await page.keyboard.press("Enter").catch(() => {});
+ }
+ // Let the challenge clear and the real app render.
+ await page.waitForLoadState("domcontentloaded").catch(() => {});
+ await page.waitForTimeout(2000);
+ return true;
+}
diff --git a/supabase/migrations/20260704140000_sp_post_verification.sql b/supabase/migrations/20260704140000_sp_post_verification.sql
new file mode 100644
index 00000000..91ac559d
--- /dev/null
+++ b/supabase/migrations/20260704140000_sp_post_verification.sql
@@ -0,0 +1,14 @@
+-- Verification-code (identity challenge) support for browser-automated posts.
+--
+-- Platforms like LinkedIn interrupt a cookie session with "enter the 6-digit
+-- code we just sent you". The worker keeps that Chromium session open, flips
+-- the post to status 'awaiting_code', and polls sp_post.verification_code for a
+-- code the user types into the UI (submitVerificationCode). Once it arrives the
+-- worker fills it in the live page and finishes posting.
+--
+-- status is a free-text column, so 'awaiting_code' needs no enum change.
+
+alter table public.sp_post
+ add column if not exists verification_code text,
+ add column if not exists verification_prompt text,
+ add column if not exists verification_requested_at timestamptz;
diff --git a/tests/sp/verification-challenge.test.ts b/tests/sp/verification-challenge.test.ts
new file mode 100644
index 00000000..16d1a40e
--- /dev/null
+++ b/tests/sp/verification-challenge.test.ts
@@ -0,0 +1,75 @@
+import { describe, it, expect } from "vitest";
+import { makeCodeWaiter } from "@/lib/sp/verificationChallenge";
+
+// In-memory stand-in for the single sp_post row the waiter reads/writes.
+function fakeSb(initial: Record = {}) {
+ const row: Record = { id: "post-1", status: "publishing", ...initial };
+ const updates: Record[] = [];
+ const sb = {
+ from() {
+ return {
+ update(payload: Record) {
+ updates.push(payload);
+ Object.assign(row, payload);
+ return { eq() { return Promise.resolve({ data: null, error: null }); } };
+ },
+ select() {
+ return {
+ eq() {
+ return {
+ maybeSingle: async () => ({
+ data: { verification_code: row.verification_code ?? null },
+ error: null,
+ }),
+ };
+ },
+ };
+ },
+ };
+ },
+ };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return { sb: sb as any, row, updates };
+}
+
+describe("makeCodeWaiter", () => {
+ it("marks the post awaiting_code, returns the submitted code, and clears it", async () => {
+ const { sb, row, updates } = fakeSb();
+ const waiter = makeCodeWaiter(sb, "post-1", { timeoutMs: 2000, pollMs: 20 });
+
+ const p = waiter("LinkedIn needs a code");
+ // Simulate the user submitting a code shortly after the prompt appears.
+ setTimeout(() => {
+ row.verification_code = "437748";
+ }, 50);
+
+ await expect(p).resolves.toBe("437748");
+
+ // First update flips to awaiting_code with the prompt.
+ expect(updates[0]).toMatchObject({
+ status: "awaiting_code",
+ verification_prompt: "LinkedIn needs a code",
+ verification_code: null,
+ });
+ // Final state: back to publishing, code consumed so it can't replay.
+ expect(row.status).toBe("publishing");
+ expect(row.verification_code).toBeNull();
+ expect(row.verification_prompt).toBeNull();
+ });
+
+ it("throws on timeout when no code is submitted", async () => {
+ const { sb } = fakeSb();
+ const waiter = makeCodeWaiter(sb, "post-1", { timeoutMs: 60, pollMs: 20 });
+ await expect(waiter("prompt")).rejects.toThrow(/timed out/i);
+ });
+
+ it("trims whitespace around a submitted code", async () => {
+ const { sb, row } = fakeSb();
+ const waiter = makeCodeWaiter(sb, "post-1", { timeoutMs: 2000, pollMs: 20 });
+ const p = waiter("prompt");
+ setTimeout(() => {
+ row.verification_code = " 123456 ";
+ }, 40);
+ await expect(p).resolves.toBe("123456");
+ });
+});