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
33 changes: 33 additions & 0 deletions app/actions/seedCredentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,39 @@ export async function saveSeedCredentialAction(input: {
};
}

/**
* Hand a verification code to a sign-in that is paused waiting for one.
*
* The browser session is still open on the other side of this, holding the
* challenge page; the runner polls this row and types whatever lands here
* into the live form. Nothing is stored — the waiter clears the column the
* moment it reads it.
*/
export async function submitSeedVerificationCodeAction(input: {
projectId: string;
host: string;
code: string;
}): Promise<{ ok: true } | Err> {
const access = await requireOrg(input.projectId);
if (!access.ok) return access;

const code = input.code.trim();
if (!code) return { ok: false, error: "Enter the code the site sent you." };
if (!/^[A-Za-z0-9-]{4,12}$/.test(code)) {
return { ok: false, error: "That doesn\u2019t look like a verification code." };
}

const { error } = await serviceClient()
.from("outreach_seed_credentials")
.update({ verification_code: code })
.eq("organization_id", access.organizationId)
.eq("host", normalizeHost(input.host));
if (error) return { ok: false, error: error.message };

revalidatePath(`/projects/${input.projectId}/leads`);
return { ok: true };
}

export async function deleteSeedCredentialAction(input: {
projectId: string;
host: string;
Expand Down
51 changes: 51 additions & 0 deletions components/leads/seed-logins.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import {
deleteSeedCredentialAction,
saveSeedCredentialAction,
submitSeedVerificationCodeAction,
} from "@/app/actions/seedCredentials";
import type { StoredSeedCredential } from "@/lib/outreach/seedCredentials";

Expand Down Expand Up @@ -34,6 +35,7 @@ export function SeedLogins({
const [password, setPassword] = useState("");
const [note, setNote] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [codes, setCodes] = useState<Record<string, string>>({});

const save = (targetHost?: string) =>
start(async () => {
Expand All @@ -57,6 +59,24 @@ export function SeedLogins({
router.refresh();
});

const sendCode = (h: string) =>
start(async () => {
setError(null);
setNote(null);
const res = await submitSeedVerificationCodeAction({
projectId,
host: h,
code: codes[h] ?? "",
});
if (!res.ok) {
setError(res.error);
return;
}
setCodes((prev) => ({ ...prev, [h]: "" }));
setNote("Code sent to the waiting sign-in.");
router.refresh();
});

const remove = (h: string) =>
start(async () => {
setError(null);
Expand Down Expand Up @@ -187,6 +207,37 @@ export function SeedLogins({
<p className="mt-1 text-xs text-[var(--color-danger,#f87171)]">{c.lastError}</p>
)}
</div>
{c.verificationPrompt && (
<div className="w-full rounded border border-[var(--color-warn,#facc15)] p-3">
<p className="text-sm">
<strong>Waiting for a verification code.</strong> {c.verificationPrompt}
</p>
<div className="mt-2 flex flex-wrap items-end gap-2">
<input
className="input w-40"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
value={codes[c.host] ?? ""}
onChange={(e) =>
setCodes((prev) => ({ ...prev, [c.host]: e.target.value }))
}
/>
<button
onClick={() => sendCode(c.host)}
disabled={pending || !(codes[c.host] ?? "").trim()}
className="btn btn-primary text-sm"
>
{pending ? "Sending…" : "Submit code"}
</button>
</div>
<p className="mt-1 text-xs text-[var(--color-muted)]">
The sign-in is held open while you fetch it. It gives up after a few
minutes so the browser is not pinned.
</p>
</div>
)}

<button
onClick={() => remove(c.host)}
disabled={pending}
Expand Down
21 changes: 16 additions & 5 deletions lib/outreach/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import { searchSerp, hasValueSerpKey } from "@/lib/alerts/valueserp";
import { isThirdPartyHost } from "@/lib/leadCampaign";
import { businessSearch } from "./freeSearch";
import { normalizeHost } from "./cold";
import { loadSeedCredential, recordSeedCredentialResult, seedHost } from "./seedCredentials";
import { loadSeedCredential, makeSeedCodeWaiter, recordSeedCredentialResult, seedHost } from "./seedCredentials";
import type { CodeWaiter } from "@/lib/sp/verificationChallenge";
import { looksLikeLoginWall } from "./loginWall";
import type { SeedCredentials } from "./seedLogin";

Expand Down Expand Up @@ -219,6 +220,7 @@ async function loadSeedHtml(
url: string,
allowRender: boolean,
credentials?: SeedCredentials | null,
codeWaiter?: CodeWaiter | null,
): Promise<{ html: string; rendered: boolean } | { error: string; loginRequired?: boolean }> {
const direct = await fetchHtml(url);
// A login wall is settled unless we hold a credential — without one,
Expand All @@ -234,7 +236,7 @@ async function loadSeedHtml(
}

const { renderPage } = await import("./render");
const rendered = await renderPage(url, { credentials });
const rendered = await renderPage(url, { credentials, codeWaiter });
if (rendered.ok) return { html: rendered.html, rendered: true };
if (rendered.loginRequired) return { error: rendered.error, loginRequired: true };

Expand All @@ -260,6 +262,8 @@ export async function discoverFromSeed(input: {
maxDetailPages?: number;
/** Sign-in for a gated directory, when one is stored for this host. */
credentials?: SeedCredentials | null;
/** Lets a user answer a verification code raised during that sign-in. */
codeWaiter?: CodeWaiter | null;
/**
* Only take the second hop when the first found fewer than this many
* businesses. Keeps ordinary directories at one cheap page load.
Expand All @@ -276,7 +280,7 @@ export async function discoverFromSeed(input: {
const allowRender = input.render !== false;
const notes: string[] = [];

const seed = await loadSeedHtml(input.seedUrl, allowRender, input.credentials);
const seed = await loadSeedHtml(input.seedUrl, allowRender, input.credentials, input.codeWaiter);
if ("error" in seed) {
return {
prospects: [],
Expand Down Expand Up @@ -308,7 +312,7 @@ export async function discoverFromSeed(input: {
}
for (const detailUrl of detailPages) {
if (merged.size >= limit) break;
const detail = await loadSeedHtml(detailUrl, allowRender, input.credentials);
const detail = await loadSeedHtml(detailUrl, allowRender, input.credentials, input.codeWaiter);
if ("error" in detail) continue;
for (const p of extractOutboundProspects({
html: detail.html,
Expand Down Expand Up @@ -441,7 +445,14 @@ export async function discoverProspects(input: {
const credentials = input.organizationId
? await loadSeedCredential(input.organizationId, seedHost(seedUrl))
: null;
const res = await discoverFromSeed({ seedUrl, limit, depth: 2, credentials });
const res = await discoverFromSeed({
seedUrl,
limit,
depth: 2,
credentials,
// Only offer the code path when a credential exists to sign in with.
codeWaiter: credentials ? makeSeedCodeWaiter(credentials.id) : null,
});
if (res.error) errors.push(res.error);
// Only "waiting on the user" when we have nothing to try. A stored
// credential that failed is a different problem and reads as an error.
Expand Down
5 changes: 4 additions & 1 deletion lib/outreach/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { Browser, BrowserContext } from "playwright";
import { isPrivateAddress } from "./mailboxDiscovery";
import { looksLikeLoginWall } from "./loginWall";
import type { SeedCredentials } from "./seedLogin";
import type { CodeWaiter } from "@/lib/sp/verificationChallenge";
import dns from "node:dns/promises";
import net from "node:net";

Expand Down Expand Up @@ -123,6 +124,8 @@ export async function renderPage(
opts?: {
/** Sign in and retry if the page turns out to be gated. */
credentials?: SeedCredentials | null;
/** Lets a user answer a verification code during that sign-in. */
codeWaiter?: CodeWaiter | null;
},
): Promise<RenderResult> {
let parsed: URL;
Expand Down Expand Up @@ -194,7 +197,7 @@ export async function renderPage(
// there — so the form is in front of us. Signing in on this same
// context keeps the session cookies for the retry.
const { submitLoginForm } = await import("./seedLogin");
const login = await submitLoginForm(page, opts.credentials);
const login = await submitLoginForm(page, opts.credentials, opts.codeWaiter ?? undefined);
if (!login.ok) {
return {
ok: false,
Expand Down
66 changes: 62 additions & 4 deletions lib/outreach/seedCredentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { serviceClient } from "@/lib/supabase/service";
import { decryptSecret } from "@/lib/sp/vault";
import { normalizeHost } from "./cold";
import type { SeedCredentials } from "./seedLogin";
import type { CodeWaiter } from "@/lib/sp/verificationChallenge";

export type StoredSeedCredential = {
id: string;
Expand All @@ -21,6 +22,8 @@ export type StoredSeedCredential = {
loginUrl: string | null;
verifiedAt: string | null;
lastError: string | null;
/** Non-null means a sign-in is paused, waiting for the user to enter a code. */
verificationPrompt: string | null;
};

/** Host key for a seed URL. Credentials are matched on this. */
Expand All @@ -41,21 +44,22 @@ export function seedHost(url: string): string {
export async function loadSeedCredential(
organizationId: string,
host: string,
): Promise<SeedCredentials | null> {
): Promise<(SeedCredentials & { id: string }) | null> {
if (!organizationId || !host) return null;
const { data } = await serviceClient()
.from("outreach_seed_credentials")
.select("username, enc_password, login_url")
.select("id, username, enc_password, login_url")
.eq("organization_id", organizationId)
.eq("host", normalizeHost(host))
.maybeSingle();
if (!data) return null;

const row = data as Record<string, string | null>;
const enc = row.enc_password;
if (!enc || !row.username) return null;
if (!enc || !row.username || !row.id) return null;
try {
return {
id: row.id,
username: row.username,
password: decryptSecret(enc),
loginUrl: row.login_url ?? undefined,
Expand All @@ -65,13 +69,66 @@ export async function loadSeedCredential(
}
}


/**
* A CodeWaiter bound to one stored credential.
*
* Same contract as the social-posting one (lib/sp/verificationChallenge.ts):
* publish what the site is asking, then poll until the user answers. The code
* is cleared the moment it is read so it cannot be replayed, and a timeout
* hands the browser slot back rather than holding it open indefinitely.
*/
export function makeSeedCodeWaiter(
credentialId: string,
opts: { timeoutMs?: number; pollMs?: number } = {},
): CodeWaiter {
const timeoutMs = opts.timeoutMs ?? 3 * 60 * 1000;
const pollMs = opts.pollMs ?? 3_000;

return async function waitForCode(prompt: string): Promise<string> {
const sb = serviceClient();
await sb
.from("outreach_seed_credentials")
.update({
verification_prompt: prompt,
verification_requested_at: new Date().toISOString(),
verification_code: null,
})
.eq("id", credentialId);

const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, pollMs));
const { data } = await sb
.from("outreach_seed_credentials")
.select("verification_code")
.eq("id", credentialId)
.maybeSingle();
const code = ((data?.verification_code as string | null) ?? "").trim();
if (code) {
await sb
.from("outreach_seed_credentials")
.update({ verification_code: null, verification_prompt: null })
.eq("id", credentialId);
return code;
}
}

await sb
.from("outreach_seed_credentials")
.update({ verification_prompt: null })
.eq("id", credentialId);
throw new Error("timed out waiting for the verification code");
};
}

/** Every stored credential for an org, without the secrets. */
export async function listSeedCredentials(
organizationId: string,
): Promise<StoredSeedCredential[]> {
const { data } = await serviceClient()
.from("outreach_seed_credentials")
.select("id, host, username, login_url, verified_at, last_error")
.select("id, host, username, login_url, verified_at, last_error, verification_prompt")
.eq("organization_id", organizationId)
.order("host");
return ((data as Record<string, string | null>[] | null) ?? []).map((r) => ({
Expand All @@ -81,6 +138,7 @@ export async function listSeedCredentials(
loginUrl: r.login_url,
verifiedAt: r.verified_at,
lastError: r.last_error,
verificationPrompt: r.verification_prompt,
}));
}

Expand Down
Loading
Loading