Simplify auth and search client, drop local JWT expiry tracking
Auth: - Remove jwt.ts — stop decoding JWT exp claims locally - Simplify OTP login: direct token extraction from verify response, remove CookieJar, BFS token extraction, session fallback, CF bypass - Storage: drop expires field, store token-only; clear on 401/403 - Single auth path: try stored token → macOS app → OTP fallback Search client: - Remove Cloudflare subprocess fallback (fetchViaBunRuntime) - Remove streamFromText, isCloudflareChallenge, BunFetchResult - Simplify SSE fetch to single fetch() call with abort signal - Let server validate tokens; clear cache on auth errors Render: - Extract shared utilities (asString, truncate) to render/util.ts - Simplify call.ts and result.ts to import from shared module Tests: - Remove jwt.test.ts (module deleted) - Add otp-flow.test.ts for email OTP authentication - Simplify login and client tests for reduced code paths Add debug scripts and plan documents.
This commit is contained in:
@@ -1,2 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.env
|
.env
|
||||||
|
local_tests/
|
||||||
|
[Pp][Ll][Aa][Nn].[Mm][Dd]
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
const FIVE_MINUTES_MS = 5 * 60 * 1000;
|
|
||||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
||||||
|
|
||||||
function decodeBase64Url(input: string): string {
|
|
||||||
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
||||||
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
|
|
||||||
return Buffer.from(padded, "base64").toString("utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Decode JWT payload and extract expiry as epoch ms (with 5min safety margin). Returns fallback of now+1h on decode failure. */
|
|
||||||
export function decodeJwtExpiry(token: string): number {
|
|
||||||
const fallback = Date.now() + ONE_HOUR_MS;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = token.split(".")[1];
|
|
||||||
if (!payload) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
const decodedPayload = decodeBase64Url(payload);
|
|
||||||
const parsed = JSON.parse(decodedPayload) as { exp?: unknown };
|
|
||||||
|
|
||||||
if (typeof parsed.exp !== "number" || !Number.isFinite(parsed.exp)) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiryMs = parsed.exp * 1000 - FIVE_MINUTES_MS;
|
|
||||||
return Number.isFinite(expiryMs) ? expiryMs : fallback;
|
|
||||||
} catch {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns true if the token is expired (with optional buffer). */
|
|
||||||
export function isJwtExpired(token: string, bufferMs = 0): boolean {
|
|
||||||
return decodeJwtExpiry(token) <= Date.now() + bufferMs;
|
|
||||||
}
|
|
||||||
+42
-265
@@ -2,23 +2,16 @@ import { execFile } from "node:child_process";
|
|||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
import { AuthError } from "../search/types.js";
|
import { AuthError } from "../search/types.js";
|
||||||
import { decodeJwtExpiry, isJwtExpired } from "./jwt.js";
|
import { errorMessage } from "../render/util.js";
|
||||||
import { clearToken, loadToken, saveToken } from "./storage.js";
|
import { loadToken, saveToken } from "./storage.js";
|
||||||
|
|
||||||
const DESKTOP_AUTH_HELP =
|
const DESKTOP_AUTH_HELP =
|
||||||
"Install the Perplexity desktop app and sign in, or set PI_AUTH_NO_BORROW=1 to skip desktop token borrowing.";
|
"Install the Perplexity desktop app and sign in, or set PI_AUTH_NO_BORROW=1 to skip desktop token borrowing.";
|
||||||
const OTP_AUTH_HELP =
|
const OTP_AUTH_HELP =
|
||||||
"Provide credentials via PI_PERPLEXITY_EMAIL and PI_PERPLEXITY_OTP, or run interactively to enter email and OTP.";
|
"Provide credentials via PI_PERPLEXITY_EMAIL and PI_PERPLEXITY_OTP, or run interactively to enter email and OTP.";
|
||||||
const AUTH_BASE_URL = "https://www.perplexity.ai/api/auth";
|
const AUTH_BASE_URL = "https://www.perplexity.ai/api/auth";
|
||||||
const AUTH_SESSION_URL = `${AUTH_BASE_URL}/session`;
|
|
||||||
const PERPLEXITY_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0";
|
const PERPLEXITY_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0";
|
||||||
const PERPLEXITY_API_VERSION = "2.18";
|
const PERPLEXITY_API_VERSION = "2.18";
|
||||||
const SESSION_COOKIE_NAMES = [
|
|
||||||
"__Secure-next-auth.session-token",
|
|
||||||
"next-auth.session-token",
|
|
||||||
"__Secure-authjs.session-token",
|
|
||||||
"authjs.session-token",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -33,165 +26,24 @@ function normalizeInput(value: string | null | undefined): string | null {
|
|||||||
return trimmed ? trimmed : null;
|
return trimmed ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeCookieValue(value: string): string {
|
function buildAuthHeaders(includeJsonContentType = false): Record<string, string> {
|
||||||
try {
|
|
||||||
return decodeURIComponent(value);
|
|
||||||
} catch {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLikelyPerplexityToken(value: unknown): value is string {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = value.trim();
|
|
||||||
if (!token || token === "(null)") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return token.includes(".") && token.length >= 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAuthHeaders(
|
|
||||||
includeJsonContentType = false,
|
|
||||||
cookieHeader?: string,
|
|
||||||
): Record<string, string> {
|
|
||||||
return {
|
return {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
...(includeJsonContentType ? { "Content-Type": "application/json" } : {}),
|
...(includeJsonContentType ? { "Content-Type": "application/json" } : {}),
|
||||||
...(cookieHeader ? { Cookie: cookieHeader } : {}),
|
|
||||||
"User-Agent": PERPLEXITY_USER_AGENT,
|
"User-Agent": PERPLEXITY_USER_AGENT,
|
||||||
"X-App-ApiVersion": PERPLEXITY_API_VERSION,
|
"X-App-ApiVersion": PERPLEXITY_API_VERSION,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSetCookieValues(headers: Headers): string[] {
|
|
||||||
const withSetCookie = headers as Headers & {
|
|
||||||
getSetCookie?: () => string[];
|
|
||||||
raw?: () => Record<string, string[]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof withSetCookie.getSetCookie === "function") {
|
|
||||||
return withSetCookie.getSetCookie();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof withSetCookie.raw === "function") {
|
|
||||||
const raw = withSetCookie.raw();
|
|
||||||
const setCookies = raw["set-cookie"];
|
|
||||||
if (Array.isArray(setCookies)) {
|
|
||||||
return setCookies;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const single = headers.get("set-cookie");
|
|
||||||
return single ? [single] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
class CookieJar {
|
|
||||||
#cookies = new Map<string, string>();
|
|
||||||
|
|
||||||
capture(headers: Headers): void {
|
|
||||||
for (const setCookie of getSetCookieValues(headers)) {
|
|
||||||
const firstPart = setCookie.split(";")[0]?.trim();
|
|
||||||
if (!firstPart) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const eqIndex = firstPart.indexOf("=");
|
|
||||||
if (eqIndex <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = firstPart.slice(0, eqIndex).trim();
|
|
||||||
const value = firstPart.slice(eqIndex + 1).trim();
|
|
||||||
if (!name || !value) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#cookies.set(name, decodeCookieValue(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toHeader(): string | undefined {
|
|
||||||
if (this.#cookies.size === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...this.#cookies.entries()].map(([name, value]) => `${name}=${value}`).join("; ");
|
|
||||||
}
|
|
||||||
|
|
||||||
get(name: string): string | undefined {
|
|
||||||
return this.#cookies.get(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTokenFromCookies(headers: Headers, jar?: CookieJar): string | null {
|
|
||||||
for (const cookieName of SESSION_COOKIE_NAMES) {
|
|
||||||
const fromJar = jar?.get(cookieName);
|
|
||||||
if (isLikelyPerplexityToken(fromJar)) {
|
|
||||||
return fromJar;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const setCookie of getSetCookieValues(headers)) {
|
|
||||||
for (const cookieName of SESSION_COOKIE_NAMES) {
|
|
||||||
const pattern = new RegExp(`(?:^|[;,]\\s*)${cookieName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=([^;]+)`);
|
|
||||||
const match = setCookie.match(pattern);
|
|
||||||
if (!match) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const decoded = decodeCookieValue(match[1] ?? "");
|
|
||||||
if (isLikelyPerplexityToken(decoded)) {
|
|
||||||
return decoded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTokenFromPayload(payload: unknown): string | null {
|
function extractTokenFromPayload(payload: unknown): string | null {
|
||||||
if (!payload || typeof payload !== "object") {
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
||||||
return null;
|
const obj = payload as Record<string, unknown>;
|
||||||
}
|
for (const key of ["token", "accessToken", "jwt", "access_token"]) {
|
||||||
|
const value = obj[key];
|
||||||
const tokenKeyPattern = /(token|jwt|access)/i;
|
if (typeof value === "string" && value.trim().length > 0) {
|
||||||
const queue: unknown[] = [payload];
|
return value.trim();
|
||||||
const seen = new Set<object>();
|
|
||||||
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const current = queue.shift();
|
|
||||||
if (!current || typeof current !== "object") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seen.has(current)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
seen.add(current);
|
|
||||||
|
|
||||||
if (Array.isArray(current)) {
|
|
||||||
for (const value of current) {
|
|
||||||
queue.push(value);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
|
|
||||||
if (tokenKeyPattern.test(key) && isLikelyPerplexityToken(value)) {
|
|
||||||
return value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value && typeof value === "object") {
|
|
||||||
queue.push(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,16 +59,14 @@ async function loginWithEmailOtp(
|
|||||||
email: string,
|
email: string,
|
||||||
options: AuthenticateOptions,
|
options: AuthenticateOptions,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const cookieJar = new CookieJar();
|
const signal = options.signal ?? null;
|
||||||
|
|
||||||
const csrfResponse = await fetch(`${AUTH_BASE_URL}/csrf`, {
|
const csrfResponse = await fetch(`${AUTH_BASE_URL}/csrf`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: buildAuthHeaders(false, cookieJar.toHeader()),
|
headers: buildAuthHeaders(),
|
||||||
signal: options.signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
cookieJar.capture(csrfResponse.headers);
|
|
||||||
|
|
||||||
if (!csrfResponse.ok) {
|
if (!csrfResponse.ok) {
|
||||||
throw new Error(`Failed to fetch CSRF token (HTTP ${csrfResponse.status}).`);
|
throw new Error(`Failed to fetch CSRF token (HTTP ${csrfResponse.status}).`);
|
||||||
}
|
}
|
||||||
@@ -229,15 +79,21 @@ async function loginWithEmailOtp(
|
|||||||
throw new Error("CSRF token missing from Perplexity auth response.");
|
throw new Error("CSRF token missing from Perplexity auth response.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cookies = csrfResponse.headers.getSetCookie?.() ?? [];
|
||||||
|
const cookieHeader = cookies.map((c) => c.split(";")[0]).join("; ");
|
||||||
|
|
||||||
|
const emailHeaders = buildAuthHeaders(true);
|
||||||
|
if (cookieHeader) {
|
||||||
|
emailHeaders.Cookie = cookieHeader;
|
||||||
|
}
|
||||||
|
|
||||||
const emailResponse = await fetch(`${AUTH_BASE_URL}/signin-email`, {
|
const emailResponse = await fetch(`${AUTH_BASE_URL}/signin-email`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(true, cookieJar.toHeader()),
|
headers: emailHeaders,
|
||||||
body: JSON.stringify({ email, csrfToken }),
|
body: JSON.stringify({ email, csrfToken }),
|
||||||
signal: options.signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
cookieJar.capture(emailResponse.headers);
|
|
||||||
|
|
||||||
if (!emailResponse.ok) {
|
if (!emailResponse.ok) {
|
||||||
throw new Error(`Failed to send OTP email (HTTP ${emailResponse.status}).`);
|
throw new Error(`Failed to send OTP email (HTTP ${emailResponse.status}).`);
|
||||||
}
|
}
|
||||||
@@ -253,60 +109,29 @@ async function loginWithEmailOtp(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const otpHeaders = buildAuthHeaders(true);
|
||||||
|
if (cookieHeader) {
|
||||||
|
otpHeaders.Cookie = cookieHeader;
|
||||||
|
}
|
||||||
|
|
||||||
const otpResponse = await fetch(`${AUTH_BASE_URL}/signin-otp`, {
|
const otpResponse = await fetch(`${AUTH_BASE_URL}/signin-otp`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(true, cookieJar.toHeader()),
|
headers: otpHeaders,
|
||||||
body: JSON.stringify({ email, otp, csrfToken }),
|
body: JSON.stringify({ email, otp, csrfToken }),
|
||||||
signal: options.signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
cookieJar.capture(otpResponse.headers);
|
|
||||||
|
|
||||||
if (!otpResponse.ok) {
|
if (!otpResponse.ok) {
|
||||||
throw new Error(`OTP verification failed (HTTP ${otpResponse.status}).`);
|
throw new Error(`OTP verification failed (HTTP ${otpResponse.status}).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const otpPayload = await readJsonResponse(otpResponse);
|
const otpPayload = await readJsonResponse(otpResponse);
|
||||||
const directToken = extractTokenFromPayload(otpPayload);
|
const token = extractTokenFromPayload(otpPayload);
|
||||||
if (directToken) {
|
if (!token) {
|
||||||
return directToken;
|
throw new Error("Perplexity OTP response did not include a token.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookieToken = extractTokenFromCookies(otpResponse.headers, cookieJar);
|
return token;
|
||||||
if (cookieToken) {
|
|
||||||
return cookieToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionResponse = await fetch(AUTH_SESSION_URL, {
|
|
||||||
method: "GET",
|
|
||||||
headers: buildAuthHeaders(false, cookieJar.toHeader()),
|
|
||||||
signal: options.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
cookieJar.capture(sessionResponse.headers);
|
|
||||||
|
|
||||||
if (sessionResponse.ok) {
|
|
||||||
const sessionPayload = await readJsonResponse(sessionResponse);
|
|
||||||
const sessionToken = extractTokenFromPayload(sessionPayload);
|
|
||||||
if (sessionToken) {
|
|
||||||
return sessionToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionCookieToken = extractTokenFromCookies(sessionResponse.headers, cookieJar);
|
|
||||||
if (sessionCookieToken) {
|
|
||||||
return sessionCookieToken;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const otpBodyHint = otpPayload ? JSON.stringify(otpPayload).slice(0, 300) : "(empty or non-JSON)";
|
|
||||||
const otpKeys =
|
|
||||||
otpPayload && typeof otpPayload === "object"
|
|
||||||
? Object.keys(otpPayload as Record<string, unknown>).join(", ")
|
|
||||||
: "N/A";
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`Perplexity OTP response did not include an access token. OTP keys: ${otpKeys}. OTP body preview: ${otpBodyHint}. Session status: ${sessionResponse.status}.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract JWT from macOS Perplexity desktop app via `defaults read`. Returns null if app not installed or not logged in. */
|
/** Extract JWT from macOS Perplexity desktop app via `defaults read`. Returns null if app not installed or not logged in. */
|
||||||
@@ -328,66 +153,28 @@ export async function extractFromDesktopApp(): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run MVP auth strategy: load cached → try desktop extraction → save → throw AuthError if all fail. */
|
/** Run auth strategy: load cached → try desktop extraction → save → throw AuthError if all fail. */
|
||||||
export async function authenticate(options: AuthenticateOptions = {}): Promise<string> {
|
export async function authenticate(options: AuthenticateOptions = {}): Promise<string> {
|
||||||
const cached = await loadToken();
|
const cached = await loadToken();
|
||||||
let sawExpiredToken = false;
|
|
||||||
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
if (cached.expires > Date.now()) {
|
return cached.access;
|
||||||
return cached.access;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isJwtExpired(cached.access)) {
|
|
||||||
return cached.access;
|
|
||||||
}
|
|
||||||
|
|
||||||
sawExpiredToken = true;
|
|
||||||
await clearToken();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const borrowDisabled = process.env.PI_AUTH_NO_BORROW === "1";
|
const borrowDisabled = process.env.PI_AUTH_NO_BORROW === "1";
|
||||||
|
|
||||||
if (!borrowDisabled) {
|
if (!borrowDisabled) {
|
||||||
let desktopToken: string | null;
|
const desktopToken = await extractFromDesktopApp();
|
||||||
|
|
||||||
try {
|
|
||||||
desktopToken = await extractFromDesktopApp();
|
|
||||||
} catch {
|
|
||||||
throw new AuthError(
|
|
||||||
"EXTRACTION_FAILED",
|
|
||||||
`Failed to read token from the Perplexity desktop app. Ensure the app is installed and signed in. ${DESKTOP_AUTH_HELP}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (desktopToken) {
|
if (desktopToken) {
|
||||||
const desktopExpiry = decodeJwtExpiry(desktopToken);
|
await saveToken({
|
||||||
if (desktopExpiry <= Date.now()) {
|
type: "oauth",
|
||||||
sawExpiredToken = true;
|
access: desktopToken,
|
||||||
} else {
|
});
|
||||||
await saveToken({
|
return desktopToken;
|
||||||
type: "oauth",
|
|
||||||
access: desktopToken,
|
|
||||||
expires: desktopExpiry,
|
|
||||||
});
|
|
||||||
|
|
||||||
return desktopToken;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const email =
|
const email =
|
||||||
normalizeInput(process.env.PI_PERPLEXITY_EMAIL) ??
|
normalizeInput(process.env.PI_PERPLEXITY_EMAIL) ??
|
||||||
normalizeInput(await options.promptForEmail?.());
|
normalizeInput(await options.promptForEmail?.());
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
if (sawExpiredToken) {
|
|
||||||
throw new AuthError(
|
|
||||||
"EXPIRED",
|
|
||||||
`Perplexity token is expired and no email was provided for OTP fallback. ${DESKTOP_AUTH_HELP} ${OTP_AUTH_HELP}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new AuthError(
|
throw new AuthError(
|
||||||
"NO_TOKEN",
|
"NO_TOKEN",
|
||||||
`Could not find a desktop token and no email was provided for OTP fallback. ${DESKTOP_AUTH_HELP} ${OTP_AUTH_HELP}`,
|
`Could not find a desktop token and no email was provided for OTP fallback. ${DESKTOP_AUTH_HELP} ${OTP_AUTH_HELP}`,
|
||||||
@@ -405,24 +192,14 @@ export async function authenticate(options: AuthenticateOptions = {}): Promise<s
|
|||||||
|
|
||||||
throw new AuthError(
|
throw new AuthError(
|
||||||
"EXTRACTION_FAILED",
|
"EXTRACTION_FAILED",
|
||||||
`Email OTP authentication failed: ${(error as Error).message}. ${OTP_AUTH_HELP}`,
|
`Email OTP authentication failed: ${errorMessage(error)}. ${OTP_AUTH_HELP}`,
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const otpExpiry = decodeJwtExpiry(otpToken);
|
|
||||||
if (otpExpiry <= Date.now()) {
|
|
||||||
throw new AuthError(
|
|
||||||
"EXPIRED",
|
|
||||||
`Perplexity returned an expired token from OTP login. Re-run login and verify OTP freshness. ${OTP_AUTH_HELP}`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveToken({
|
await saveToken({
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
access: otpToken,
|
access: otpToken,
|
||||||
expires: otpExpiry,
|
|
||||||
email,
|
email,
|
||||||
});
|
});
|
||||||
|
|
||||||
return otpToken;
|
return otpToken;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-8
@@ -1,4 +1,4 @@
|
|||||||
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
@@ -7,17 +7,15 @@ import type { StoredToken } from "../search/types.js";
|
|||||||
const TOKEN_PATH = join(homedir(), ".config", "pi-perplexity", "auth.json");
|
const TOKEN_PATH = join(homedir(), ".config", "pi-perplexity", "auth.json");
|
||||||
|
|
||||||
function isStoredToken(value: unknown): value is StoredToken {
|
function isStoredToken(value: unknown): value is StoredToken {
|
||||||
if (!value || typeof value !== "object") {
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidate = value as Partial<StoredToken>;
|
const candidate = value as Record<string, unknown>;
|
||||||
return (
|
return (
|
||||||
candidate.type === "oauth" &&
|
candidate.type === "oauth" &&
|
||||||
typeof candidate.access === "string" &&
|
typeof candidate.access === "string" &&
|
||||||
candidate.access.length > 0 &&
|
candidate.access.length > 0
|
||||||
typeof candidate.expires === "number" &&
|
|
||||||
Number.isFinite(candidate.expires)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,8 +37,7 @@ export async function loadToken(): Promise<StoredToken | null> {
|
|||||||
/** Save token to disk with 0600 permissions. Creates directory if needed. */
|
/** Save token to disk with 0600 permissions. Creates directory if needed. */
|
||||||
export async function saveToken(token: StoredToken): Promise<void> {
|
export async function saveToken(token: StoredToken): Promise<void> {
|
||||||
await mkdir(dirname(TOKEN_PATH), { recursive: true });
|
await mkdir(dirname(TOKEN_PATH), { recursive: true });
|
||||||
await writeFile(TOKEN_PATH, `${JSON.stringify(token, null, 2)}\n`, "utf8");
|
await writeFile(TOKEN_PATH, `${JSON.stringify(token, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
||||||
await chmod(TOKEN_PATH, 0o600);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete the stored token file. No-op if missing. */
|
/** Delete the stored token file. No-op if missing. */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|||||||
import { authenticate } from "../auth/login.js";
|
import { authenticate } from "../auth/login.js";
|
||||||
import { clearToken } from "../auth/storage.js";
|
import { clearToken } from "../auth/storage.js";
|
||||||
import { AuthError } from "../search/types.js";
|
import { AuthError } from "../search/types.js";
|
||||||
|
import { errorMessage } from "../render/util.js";
|
||||||
|
|
||||||
const LOGIN_COMMAND_NAME = "perplexity-login";
|
const LOGIN_COMMAND_NAME = "perplexity-login";
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ export function registerPerplexityCommands(pi: ExtensionAPI): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.ui.notify(`Perplexity login failed: ${(error as Error).message || "Unknown error"}`, "error");
|
ctx.ui.notify(`Perplexity login failed: ${errorMessage(error)}`, "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-14
@@ -11,6 +11,7 @@ import { searchPerplexity } from "./search/client.js";
|
|||||||
import { renderPerplexityCall } from "./render/call.js";
|
import { renderPerplexityCall } from "./render/call.js";
|
||||||
import { renderPerplexityResult } from "./render/result.js";
|
import { renderPerplexityResult } from "./render/result.js";
|
||||||
import { AuthError, SearchError } from "./search/types.js";
|
import { AuthError, SearchError } from "./search/types.js";
|
||||||
|
import { errorMessage } from "./render/util.js";
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
export default function (pi: ExtensionAPI) {
|
||||||
registerPerplexityCommands(pi);
|
registerPerplexityCommands(pi);
|
||||||
@@ -49,11 +50,13 @@ export default function (pi: ExtensionAPI) {
|
|||||||
return ctx.ui.input(label, placeholder);
|
return ctx.ui.input(label, placeholder);
|
||||||
};
|
};
|
||||||
|
|
||||||
const jwt = await authenticate({
|
const authOptions: Parameters<typeof authenticate>[0] = {
|
||||||
signal,
|
|
||||||
promptForEmail: async () => promptInput("Perplexity email", "you@example.com"),
|
promptForEmail: async () => promptInput("Perplexity email", "you@example.com"),
|
||||||
promptForOtp: async (email) => promptInput(`Enter OTP sent to ${email}`, "123456"),
|
promptForOtp: async (email) => promptInput(`Enter OTP sent to ${email}`, "123456"),
|
||||||
});
|
};
|
||||||
|
if (signal) authOptions.signal = signal;
|
||||||
|
|
||||||
|
const jwt = await authenticate(authOptions);
|
||||||
|
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
return {
|
return {
|
||||||
@@ -67,15 +70,13 @@ export default function (pi: ExtensionAPI) {
|
|||||||
details: { toolCallId },
|
details: { toolCallId },
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await searchPerplexity(
|
const searchParams: Parameters<typeof searchPerplexity>[0] = {
|
||||||
{
|
query: params.query,
|
||||||
query: params.query,
|
};
|
||||||
recency: params.recency,
|
if (params.recency) searchParams.recency = params.recency;
|
||||||
limit: params.limit,
|
if (typeof params.limit === "number") searchParams.limit = params.limit;
|
||||||
},
|
|
||||||
jwt,
|
const result = await searchPerplexity(searchParams, jwt, signal);
|
||||||
signal,
|
|
||||||
);
|
|
||||||
|
|
||||||
const formatted = formatForLLM(result, params.limit);
|
const formatted = formatForLLM(result, params.limit);
|
||||||
sourceCount =
|
sourceCount =
|
||||||
@@ -106,7 +107,6 @@ export default function (pi: ExtensionAPI) {
|
|||||||
if (error.code === "AUTH") {
|
if (error.code === "AUTH") {
|
||||||
await clearToken().catch(() => undefined);
|
await clearToken().catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `Perplexity search failed: ${error.message}` }],
|
content: [{ type: "text", text: `Perplexity search failed: ${error.message}` }],
|
||||||
details: { sourceCount, queryMs },
|
details: { sourceCount, queryMs },
|
||||||
@@ -117,7 +117,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `Perplexity search failed: ${(error as Error).message || "Unknown error"}`,
|
text: `Perplexity search failed: ${errorMessage(error)}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
details: { sourceCount, queryMs },
|
details: { sourceCount, queryMs },
|
||||||
|
|||||||
+1
-21
@@ -1,5 +1,6 @@
|
|||||||
import type { Theme } from "@mariozechner/pi-coding-agent";
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
||||||
import { Text } from "@mariozechner/pi-tui";
|
import { Text } from "@mariozechner/pi-tui";
|
||||||
|
import { asString, asPositiveNumber, truncate } from "./util.js";
|
||||||
|
|
||||||
interface PerplexityCallArgs {
|
interface PerplexityCallArgs {
|
||||||
query?: unknown;
|
query?: unknown;
|
||||||
@@ -8,27 +9,6 @@ interface PerplexityCallArgs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RECENCY_VALUES = new Set(["hour", "day", "week", "month", "year"] as const);
|
const RECENCY_VALUES = new Set(["hour", "day", "week", "month", "year"] as const);
|
||||||
|
|
||||||
function asString(value: unknown): string | undefined {
|
|
||||||
return typeof value === "string" ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asPositiveNumber(value: unknown): number | undefined {
|
|
||||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncate(text: string, maxLength: number): string {
|
|
||||||
if (text.length <= maxLength) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${text.slice(0, Math.max(1, maxLength - 1))}…`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderPerplexityCall(args: PerplexityCallArgs, theme: Theme): Text {
|
export function renderPerplexityCall(args: PerplexityCallArgs, theme: Theme): Text {
|
||||||
const query = asString(args?.query)?.trim();
|
const query = asString(args?.query)?.trim();
|
||||||
const recencyRaw = asString(args?.recency)?.trim().toLowerCase();
|
const recencyRaw = asString(args?.recency)?.trim().toLowerCase();
|
||||||
|
|||||||
+6
-18
@@ -1,5 +1,6 @@
|
|||||||
import type { AgentToolResult, Theme, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
|
import type { AgentToolResult, Theme, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
|
||||||
import { Text } from "@mariozechner/pi-tui";
|
import { Text } from "@mariozechner/pi-tui";
|
||||||
|
import { asString, asNumber, truncate } from "./util.js";
|
||||||
|
|
||||||
interface PerplexityResultDetails {
|
interface PerplexityResultDetails {
|
||||||
model?: unknown;
|
model?: unknown;
|
||||||
@@ -9,23 +10,6 @@ interface PerplexityResultDetails {
|
|||||||
toolCallId?: unknown;
|
toolCallId?: unknown;
|
||||||
error?: unknown;
|
error?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
function asString(value: unknown): string | undefined {
|
|
||||||
return typeof value === "string" ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asNumber(value: unknown): number | undefined {
|
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncate(text: string, maxLength: number): string {
|
|
||||||
if (text.length <= maxLength) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${text.slice(0, Math.max(1, maxLength - 1))}…`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTextContent(result: AgentToolResult<PerplexityResultDetails>): string | undefined {
|
function extractTextContent(result: AgentToolResult<PerplexityResultDetails>): string | undefined {
|
||||||
if (!Array.isArray(result?.content)) {
|
if (!Array.isArray(result?.content)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -63,7 +47,11 @@ export function renderPerplexityResult(
|
|||||||
options: ToolRenderResultOptions,
|
options: ToolRenderResultOptions,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
): Text {
|
): Text {
|
||||||
const details = (result?.details ?? {}) as PerplexityResultDetails;
|
const raw = result?.details;
|
||||||
|
const details: PerplexityResultDetails =
|
||||||
|
raw && typeof raw === "object" && !Array.isArray(raw)
|
||||||
|
? (raw as PerplexityResultDetails)
|
||||||
|
: {};
|
||||||
const contentText = extractTextContent(result);
|
const contentText = extractTextContent(result);
|
||||||
|
|
||||||
if (options?.isPartial) {
|
if (options?.isPartial) {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export function asString(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Safely extract a message from an unknown caught value. */
|
||||||
|
export function errorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
if (typeof error === "string") return error;
|
||||||
|
return "Unknown error";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asNumber(value: unknown): number | undefined {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asPositiveNumber(value: unknown): number | undefined {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(text: string, maxLength: number): string {
|
||||||
|
if (text.length <= maxLength) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return `${text.slice(0, Math.max(1, maxLength - 1))}…`;
|
||||||
|
}
|
||||||
+164
-188
@@ -1,16 +1,105 @@
|
|||||||
import { execFile } from "node:child_process";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
|
|
||||||
import { mergeEvent, readSseEvents } from "./stream.js";
|
import { mergeEvent, readSseEvents } from "./stream.js";
|
||||||
import type { SearchResult, StreamEvent, WebResult } from "./types.js";
|
import type { SearchResult, StreamEvent, WebResult } from "./types.js";
|
||||||
import { SearchError } from "./types.js";
|
import { SearchError } from "./types.js";
|
||||||
|
import { errorMessage } from "../render/util.js";
|
||||||
|
|
||||||
const PERPLEXITY_ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask";
|
const PERPLEXITY_ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask";
|
||||||
const PERPLEXITY_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0";
|
const PERPLEXITY_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0";
|
||||||
const MAX_BUN_STDOUT_BYTES = 50 * 1024 * 1024;
|
|
||||||
const CLOUDFLARE_HINTS = ["just a moment", "cloudflare", "cf-chl", "cf-ray"];
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
function streamFromText(text: string): ReadableStream<Uint8Array> {
|
||||||
|
const bytes = new TextEncoder().encode(text);
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(bytes);
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_BUN_STDOUT = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a Perplexity request via a Bun subprocess.
|
||||||
|
* Pi loads extensions under Node/jiti whose fetch gets Cloudflare-challenged.
|
||||||
|
* Bun's native fetch has a different TLS fingerprint that passes.
|
||||||
|
*/
|
||||||
|
async function fetchViaBunRuntime(
|
||||||
|
url: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
body: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{ status: number; bodyText: string }> {
|
||||||
|
const script = `
|
||||||
|
const c = JSON.parse(await Bun.stdin.text());
|
||||||
|
try {
|
||||||
|
const r = await fetch(c.url, { method: "POST", headers: c.headers, body: c.body });
|
||||||
|
const t = await r.text();
|
||||||
|
process.stdout.write(JSON.stringify({ s: r.status, b: t }));
|
||||||
|
} catch (e) {
|
||||||
|
process.stdout.write(JSON.stringify({ s: 0, b: String(e?.message ?? e) }));
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Dynamic import: spawn is only needed under Node/jiti (not Bun),
|
||||||
|
// and Bun's node:child_process polyfill may not export it.
|
||||||
|
const { spawn } = await import("node:child_process");
|
||||||
|
|
||||||
|
const stdout = await new Promise<string>((resolve, reject) => {
|
||||||
|
const child = spawn("bun", ["-e", script], {
|
||||||
|
stdio: ["pipe", "pipe", "ignore"],
|
||||||
|
env: { HOME: process.env.HOME, PATH: process.env.PATH },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
const onAbort = () => child.kill();
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
child.on("close", () => signal.removeEventListener("abort", onAbort));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!child.stdin || !child.stdout) {
|
||||||
|
reject(new Error("Failed to open subprocess pipes"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdin.write(JSON.stringify({ url, headers, body }));
|
||||||
|
child.stdin.end();
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let totalLen = 0;
|
||||||
|
child.stdout.on("data", (chunk: Buffer) => {
|
||||||
|
totalLen += chunk.length;
|
||||||
|
if (totalLen <= MAX_BUN_STDOUT) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||||
|
child.on("error", reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(stdout);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Bun subprocess returned invalid output: ${stdout.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!parsed ||
|
||||||
|
typeof parsed !== "object" ||
|
||||||
|
Array.isArray(parsed)
|
||||||
|
) {
|
||||||
|
throw new Error("Bun subprocess response is not an object.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = parsed as Record<string, unknown>;
|
||||||
|
if (typeof obj.s !== "number" || typeof obj.b !== "string") {
|
||||||
|
throw new Error("Bun subprocess response missing required fields.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: obj.s, bodyText: obj.b };
|
||||||
|
}
|
||||||
|
|
||||||
export interface SearchParams {
|
export interface SearchParams {
|
||||||
query: string;
|
query: string;
|
||||||
@@ -18,12 +107,6 @@ export interface SearchParams {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BunFetchResult {
|
|
||||||
status: number;
|
|
||||||
contentType: string | null;
|
|
||||||
bodyText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeUrl(url: string): string {
|
function normalizeUrl(url: string): string {
|
||||||
return url.trim().replace(/\/$/, "").toLowerCase();
|
return url.trim().replace(/\/$/, "").toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -104,12 +187,14 @@ function extractSources(event: StreamEvent): WebResult[] {
|
|||||||
return dedupeSourcesByUrl(blockSources);
|
return dedupeSourcesByUrl(blockSources);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackSources: WebResult[] = (event.sources_list ?? []).map((source) => ({
|
const fallbackSources: WebResult[] = (event.sources_list ?? []).map((source) => {
|
||||||
name: source.title,
|
const result: WebResult = {};
|
||||||
url: source.url,
|
if (source.title !== undefined) result.name = source.title;
|
||||||
snippet: source.snippet,
|
if (source.url !== undefined) result.url = source.url;
|
||||||
timestamp: source.date,
|
if (source.snippet !== undefined) result.snippet = source.snippet;
|
||||||
}));
|
if (source.date !== undefined) result.timestamp = source.date;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
return dedupeSourcesByUrl(fallbackSources);
|
return dedupeSourcesByUrl(fallbackSources);
|
||||||
}
|
}
|
||||||
@@ -155,30 +240,8 @@ function buildRequestHeaders(jwt: string, requestId: string): Record<string, str
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCloudflareChallenge(status: number, contentType: string | null, bodyText: string): boolean {
|
function mapHttpError(status: number): SearchError {
|
||||||
if (status !== 403) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentTypeLower = (contentType ?? "").toLowerCase();
|
|
||||||
const bodyLower = bodyText.toLowerCase();
|
|
||||||
|
|
||||||
if (!contentTypeLower.includes("text/html")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return CLOUDFLARE_HINTS.some((hint) => bodyLower.includes(hint));
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapHttpError(status: number, bodyText = "", contentType: string | null = null): SearchError {
|
|
||||||
if (status === 401 || status === 403) {
|
if (status === 401 || status === 403) {
|
||||||
if (isCloudflareChallenge(status, contentType, bodyText)) {
|
|
||||||
return new SearchError(
|
|
||||||
"NETWORK",
|
|
||||||
"Perplexity request was blocked by Cloudflare challenge in this runtime. Retry via Bun runtime fallback or desktop app token path.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new SearchError(
|
return new SearchError(
|
||||||
"AUTH",
|
"AUTH",
|
||||||
"Perplexity rejected authentication (401/403). Sign in to Perplexity desktop app and retry.",
|
"Perplexity rejected authentication (401/403). Sign in to Perplexity desktop app and retry.",
|
||||||
@@ -197,101 +260,6 @@ function mapHttpError(status: number, bodyText = "", contentType: string | null
|
|||||||
`Perplexity request failed with HTTP ${status}. Check connectivity and retry.`,
|
`Perplexity request failed with HTTP ${status}. Check connectivity and retry.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function streamFromText(text: string): ReadableStream<Uint8Array> {
|
|
||||||
const bytes = new TextEncoder().encode(text);
|
|
||||||
|
|
||||||
return new ReadableStream<Uint8Array>({
|
|
||||||
start(controller) {
|
|
||||||
controller.enqueue(bytes);
|
|
||||||
controller.close();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchViaBunRuntime(
|
|
||||||
requestBody: Record<string, unknown>,
|
|
||||||
jwt: string,
|
|
||||||
requestId: string,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<BunFetchResult> {
|
|
||||||
const script = `
|
|
||||||
const endpoint = process.env.PI_PPLX_ENDPOINT;
|
|
||||||
const token = process.env.PI_PPLX_TOKEN;
|
|
||||||
const body = JSON.parse(process.env.PI_PPLX_BODY || "{}");
|
|
||||||
const requestId = process.env.PI_PPLX_REQUEST_ID || crypto.randomUUID();
|
|
||||||
try {
|
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: \`Bearer \${token}\`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Accept: "text/event-stream",
|
|
||||||
Origin: "https://www.perplexity.ai",
|
|
||||||
Referer: "https://www.perplexity.ai/",
|
|
||||||
"User-Agent": "${PERPLEXITY_USER_AGENT}",
|
|
||||||
"X-App-ApiClient": "default",
|
|
||||||
"X-App-ApiVersion": "2.18",
|
|
||||||
"X-Perplexity-Request-Reason": "submit",
|
|
||||||
"X-Request-ID": requestId,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
const text = await response.text();
|
|
||||||
process.stdout.write(JSON.stringify({
|
|
||||||
status: response.status,
|
|
||||||
contentType: response.headers.get("content-type"),
|
|
||||||
bodyText: text,
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
process.stdout.write(JSON.stringify({
|
|
||||||
status: 0,
|
|
||||||
contentType: null,
|
|
||||||
bodyText: String(error && error.message ? error.message : error),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const { stdout } = await execFileAsync(
|
|
||||||
"bun",
|
|
||||||
["-e", script],
|
|
||||||
{
|
|
||||||
encoding: "utf8",
|
|
||||||
maxBuffer: MAX_BUN_STDOUT_BYTES,
|
|
||||||
signal,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
PI_PPLX_ENDPOINT: PERPLEXITY_ENDPOINT,
|
|
||||||
PI_PPLX_TOKEN: jwt,
|
|
||||||
PI_PPLX_BODY: JSON.stringify(requestBody),
|
|
||||||
PI_PPLX_REQUEST_ID: requestId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(stdout);
|
|
||||||
} catch {
|
|
||||||
throw new Error("Bun fallback returned non-JSON output.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsed || typeof parsed !== "object") {
|
|
||||||
throw new Error("Bun fallback returned invalid payload.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = parsed as Partial<BunFetchResult>;
|
|
||||||
if (typeof result.status !== "number" || typeof result.bodyText !== "string") {
|
|
||||||
throw new Error("Bun fallback response missing required fields.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: result.status,
|
|
||||||
contentType: typeof result.contentType === "string" ? result.contentType : null,
|
|
||||||
bodyText: result.bodyText,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Execute a Perplexity search: POST SSE, stream/merge events, extract answer + sources. Throws SearchError on failure. */
|
/** Execute a Perplexity search: POST SSE, stream/merge events, extract answer + sources. Throws SearchError on failure. */
|
||||||
export async function searchPerplexity(
|
export async function searchPerplexity(
|
||||||
params: SearchParams,
|
params: SearchParams,
|
||||||
@@ -302,57 +270,67 @@ export async function searchPerplexity(
|
|||||||
const requestBody = buildRequestBody(params);
|
const requestBody = buildRequestBody(params);
|
||||||
const requestHeaders = buildRequestHeaders(jwt, requestId);
|
const requestHeaders = buildRequestHeaders(jwt, requestId);
|
||||||
|
|
||||||
let response: Response;
|
let eventStream: ReadableStream<Uint8Array>;
|
||||||
try {
|
|
||||||
response = await fetch(PERPLEXITY_ENDPOINT, {
|
|
||||||
method: "POST",
|
|
||||||
headers: requestHeaders,
|
|
||||||
body: JSON.stringify(requestBody),
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (signal?.aborted) {
|
|
||||||
throw new SearchError("NETWORK", "Perplexity request was cancelled.");
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new SearchError(
|
// Bun's native fetch passes Cloudflare; Node/jiti's fetch gets challenged.
|
||||||
"NETWORK",
|
// Use native fetch when running under Bun (tests, direct scripts),
|
||||||
`Could not connect to Perplexity. ${(error as Error).message || "Network failure."}`,
|
// subprocess fallback when running under Node/jiti (pi extension runtime).
|
||||||
);
|
const useBunSubprocess = typeof Bun === "undefined";
|
||||||
}
|
|
||||||
|
|
||||||
let eventStream: ReadableStream<Uint8Array> | null = null;
|
if (useBunSubprocess) {
|
||||||
|
let bunResult: { status: number; bodyText: string };
|
||||||
if (!response.ok) {
|
|
||||||
let bodyText = "";
|
|
||||||
try {
|
try {
|
||||||
bodyText = await response.text();
|
bunResult = await fetchViaBunRuntime(
|
||||||
} catch {
|
PERPLEXITY_ENDPOINT,
|
||||||
bodyText = "";
|
requestHeaders,
|
||||||
}
|
JSON.stringify(requestBody),
|
||||||
|
signal,
|
||||||
const contentType = response.headers.get("content-type");
|
);
|
||||||
|
} catch (error) {
|
||||||
if (isCloudflareChallenge(response.status, contentType, bodyText)) {
|
if (signal?.aborted) {
|
||||||
let bunResult: BunFetchResult;
|
throw new SearchError("NETWORK", "Perplexity request was cancelled.");
|
||||||
try {
|
|
||||||
bunResult = await fetchViaBunRuntime(requestBody, jwt, requestId, signal);
|
|
||||||
} catch (error) {
|
|
||||||
throw new SearchError(
|
|
||||||
"NETWORK",
|
|
||||||
`Perplexity request hit Cloudflare challenge and Bun fallback failed: ${(error as Error).message || "unknown error"}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bunResult.status !== 200) {
|
throw new SearchError(
|
||||||
throw mapHttpError(bunResult.status, bunResult.bodyText, bunResult.contentType);
|
"NETWORK",
|
||||||
}
|
`Could not connect to Perplexity. ${errorMessage(error)}`,
|
||||||
|
);
|
||||||
eventStream = streamFromText(bunResult.bodyText);
|
|
||||||
} else {
|
|
||||||
throw mapHttpError(response.status, bodyText, contentType);
|
|
||||||
}
|
}
|
||||||
|
if (bunResult.status === 0) {
|
||||||
|
throw new SearchError("NETWORK", bunResult.bodyText);
|
||||||
|
}
|
||||||
|
if (bunResult.status !== 200) {
|
||||||
|
throw mapHttpError(bunResult.status);
|
||||||
|
}
|
||||||
|
if (!bunResult.bodyText) {
|
||||||
|
throw new SearchError("STREAM", "Perplexity returned an empty response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
eventStream = streamFromText(bunResult.bodyText);
|
||||||
} else {
|
} else {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(PERPLEXITY_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: requestHeaders,
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
signal: signal ?? null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new SearchError("NETWORK", "Perplexity request was cancelled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new SearchError(
|
||||||
|
"NETWORK",
|
||||||
|
`Could not connect to Perplexity. ${errorMessage(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw mapHttpError(response.status);
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.body) {
|
if (!response.body) {
|
||||||
throw new SearchError("STREAM", "Perplexity returned an empty stream body.");
|
throw new SearchError("STREAM", "Perplexity returned an empty stream body.");
|
||||||
}
|
}
|
||||||
@@ -360,10 +338,6 @@ export async function searchPerplexity(
|
|||||||
eventStream = response.body;
|
eventStream = response.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!eventStream) {
|
|
||||||
throw new SearchError("STREAM", "Perplexity returned no readable stream.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let snapshot: StreamEvent = {};
|
let snapshot: StreamEvent = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -384,7 +358,7 @@ export async function searchPerplexity(
|
|||||||
|
|
||||||
throw new SearchError(
|
throw new SearchError(
|
||||||
"STREAM",
|
"STREAM",
|
||||||
`Failed to parse Perplexity stream: ${(error as Error).message || "unknown error"}`,
|
`Failed to parse Perplexity stream: ${errorMessage(error)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,10 +379,12 @@ export async function searchPerplexity(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const result: SearchResult = {
|
||||||
answer: answer || "No answer text returned by Perplexity.",
|
answer: answer || "No answer text returned by Perplexity.",
|
||||||
sources,
|
sources,
|
||||||
displayModel: snapshot.display_model,
|
|
||||||
uuid: snapshot.uuid,
|
|
||||||
};
|
};
|
||||||
|
if (snapshot.display_model !== undefined) result.displayModel = snapshot.display_model;
|
||||||
|
if (snapshot.uuid !== undefined) result.uuid = snapshot.uuid;
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-4
@@ -1,5 +1,9 @@
|
|||||||
import type { StreamBlock, StreamEvent } from "./types.js";
|
import type { StreamBlock, StreamEvent } from "./types.js";
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
function parseEventPayload(payload: string): StreamEvent | null {
|
function parseEventPayload(payload: string): StreamEvent | null {
|
||||||
const trimmed = payload.trim();
|
const trimmed = payload.trim();
|
||||||
if (!trimmed || trimmed === "[DONE]") {
|
if (!trimmed || trimmed === "[DONE]") {
|
||||||
@@ -7,10 +11,11 @@ function parseEventPayload(payload: string): StreamEvent | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(trimmed) as unknown;
|
const parsed: unknown = JSON.parse(trimmed);
|
||||||
if (!parsed || typeof parsed !== "object") {
|
if (!isPlainObject(parsed)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// StreamEvent fields are all optional — any plain object is a valid shape.
|
||||||
return parsed as StreamEvent;
|
return parsed as StreamEvent;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -126,12 +131,16 @@ export function mergeMarkdownBlock(
|
|||||||
(mergedChunks.length > 0 ? mergedChunks.join("") : undefined) ??
|
(mergedChunks.length > 0 ? mergedChunks.join("") : undefined) ??
|
||||||
existing.answer;
|
existing.answer;
|
||||||
|
|
||||||
return {
|
const result: { answer?: string; chunks?: string[]; chunk_starting_offset?: number } = {
|
||||||
...existing,
|
...existing,
|
||||||
...incoming,
|
...incoming,
|
||||||
answer: mergedAnswer,
|
|
||||||
chunks: mergedChunks,
|
chunks: mergedChunks,
|
||||||
};
|
};
|
||||||
|
if (mergedAnswer !== undefined) {
|
||||||
|
result.answer = mergedAnswer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeSingleBlock(existing: StreamBlock, incoming: StreamBlock): StreamBlock {
|
function mergeSingleBlock(existing: StreamBlock, incoming: StreamBlock): StreamBlock {
|
||||||
|
|||||||
+1
-2
@@ -43,7 +43,6 @@ export interface StreamSource {
|
|||||||
export interface StoredToken {
|
export interface StoredToken {
|
||||||
type: "oauth";
|
type: "oauth";
|
||||||
access: string;
|
access: string;
|
||||||
expires: number;
|
|
||||||
email?: string;
|
email?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +69,7 @@ export class SearchError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AuthErrorCode = "NO_TOKEN" | "EXPIRED" | "EXTRACTION_FAILED";
|
export type AuthErrorCode = "NO_TOKEN" | "EXTRACTION_FAILED";
|
||||||
|
|
||||||
export class AuthError extends Error {
|
export class AuthError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
||||||
|
|
||||||
import { decodeJwtExpiry, isJwtExpired } from "../../src/auth/jwt.js";
|
|
||||||
|
|
||||||
const FIXED_NOW = Date.UTC(2026, 1, 16, 12, 0, 0);
|
|
||||||
|
|
||||||
function createJwt(expSeconds: number): string {
|
|
||||||
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
|
||||||
const payload = Buffer.from(JSON.stringify({ exp: expSeconds })).toString("base64url");
|
|
||||||
return `${header}.${payload}.signature`;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("jwt helpers", () => {
|
|
||||||
const originalNow = Date.now;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
Date.now = () => FIXED_NOW;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
Date.now = originalNow;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("decodeJwtExpiry returns expiry in ms with 5 minute safety margin", () => {
|
|
||||||
const expSeconds = Math.floor((FIXED_NOW + 2 * 60 * 60 * 1000) / 1000);
|
|
||||||
const token = createJwt(expSeconds);
|
|
||||||
|
|
||||||
expect(decodeJwtExpiry(token)).toBe(expSeconds * 1000 - 5 * 60 * 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("decodeJwtExpiry falls back to now + 1h when token is malformed", () => {
|
|
||||||
expect(decodeJwtExpiry("not-a-jwt")).toBe(FIXED_NOW + 60 * 60 * 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("isJwtExpired honors additional caller-provided buffer", () => {
|
|
||||||
const expSeconds = Math.floor((FIXED_NOW + 20 * 60 * 1000) / 1000);
|
|
||||||
const token = createJwt(expSeconds);
|
|
||||||
|
|
||||||
expect(isJwtExpired(token)).toBe(false);
|
|
||||||
expect(isJwtExpired(token, 16 * 60 * 1000)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+1
-130
@@ -119,12 +119,11 @@ describe("auth/login", () => {
|
|||||||
expect(token).toBe(desktopToken);
|
expect(token).toBe(desktopToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("authenticate returns non-expired cached token without desktop or OTP calls", async () => {
|
test("authenticate returns cached token without desktop or OTP calls", async () => {
|
||||||
const cachedToken = createJwt(Date.now() + 2 * 60 * 60 * 1000);
|
const cachedToken = createJwt(Date.now() + 2 * 60 * 60 * 1000);
|
||||||
const loadTokenMock = mock(async () => ({
|
const loadTokenMock = mock(async () => ({
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
access: cachedToken,
|
access: cachedToken,
|
||||||
expires: Date.now() + 60 * 60 * 1000,
|
|
||||||
}) satisfies StoredToken);
|
}) satisfies StoredToken);
|
||||||
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
|
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
|
||||||
const clearTokenMock = mock(async () => undefined);
|
const clearTokenMock = mock(async () => undefined);
|
||||||
@@ -234,134 +233,6 @@ describe("auth/login", () => {
|
|||||||
|
|
||||||
expect(clearTokenMock).toHaveBeenCalledTimes(0);
|
expect(clearTokenMock).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("authenticate accepts OTP token from session cookie when body has no token", async () => {
|
|
||||||
process.env.PI_AUTH_NO_BORROW = "1";
|
|
||||||
|
|
||||||
const otpToken = createOpaqueToken();
|
|
||||||
const loadTokenMock = mock(async () => null);
|
|
||||||
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
|
|
||||||
const clearTokenMock = mock(async () => undefined);
|
|
||||||
|
|
||||||
mock.module("../../src/auth/storage.js", () => ({
|
|
||||||
loadToken: loadTokenMock,
|
|
||||||
saveToken: saveTokenMock,
|
|
||||||
clearToken: clearTokenMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const fetchMock = mock(async (input: RequestInfo | URL) => {
|
|
||||||
const url = String(input);
|
|
||||||
|
|
||||||
if (url.endsWith("/csrf")) {
|
|
||||||
return new Response(JSON.stringify({ csrfToken: "csrf-token" }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.endsWith("/signin-email")) {
|
|
||||||
return new Response(JSON.stringify({ ok: true }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.endsWith("/signin-otp")) {
|
|
||||||
return new Response(JSON.stringify({ status: "ok" }), {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"content-type": "application/json",
|
|
||||||
"set-cookie": `__Secure-next-auth.session-token=${encodeURIComponent(otpToken)}; Path=/; HttpOnly`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response("not found", { status: 404 });
|
|
||||||
});
|
|
||||||
|
|
||||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const { authenticate } = await importLoginModule();
|
|
||||||
|
|
||||||
const token = await authenticate({
|
|
||||||
promptForEmail: async () => "user@example.com",
|
|
||||||
promptForOtp: async () => "123456",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(token).toBe(otpToken);
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
||||||
expect(saveTokenMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("authenticate falls back to /session when OTP body has no token", async () => {
|
|
||||||
process.env.PI_AUTH_NO_BORROW = "1";
|
|
||||||
|
|
||||||
const otpToken = createOpaqueToken();
|
|
||||||
const loadTokenMock = mock(async () => null);
|
|
||||||
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
|
|
||||||
const clearTokenMock = mock(async () => undefined);
|
|
||||||
|
|
||||||
mock.module("../../src/auth/storage.js", () => ({
|
|
||||||
loadToken: loadTokenMock,
|
|
||||||
saveToken: saveTokenMock,
|
|
||||||
clearToken: clearTokenMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
const url = String(input);
|
|
||||||
|
|
||||||
if (url.endsWith("/csrf")) {
|
|
||||||
return new Response(JSON.stringify({ csrfToken: "csrf-token" }), {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"content-type": "application/json",
|
|
||||||
"set-cookie": "next-auth.csrf-token=csrf-cookie; Path=/",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.endsWith("/signin-email")) {
|
|
||||||
return new Response(JSON.stringify({ ok: true }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.endsWith("/signin-otp")) {
|
|
||||||
return new Response(JSON.stringify({ status: "ok" }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.endsWith("/session")) {
|
|
||||||
const cookieHeader = new Headers(init?.headers).get("Cookie") ?? "";
|
|
||||||
expect(cookieHeader).toContain("next-auth.csrf-token=csrf-cookie");
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ token: otpToken }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response("not found", { status: 404 });
|
|
||||||
});
|
|
||||||
|
|
||||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const { authenticate } = await importLoginModule();
|
|
||||||
|
|
||||||
const token = await authenticate({
|
|
||||||
promptForEmail: async () => "user@example.com",
|
|
||||||
promptForOtp: async () => "123456",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(token).toBe(otpToken);
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
||||||
expect(saveTokenMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(clearTokenMock).toHaveBeenCalledTimes(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("authenticate throws NO_TOKEN when no cached token and no OTP email input", async () => {
|
test("authenticate throws NO_TOKEN when no cached token and no OTP email input", async () => {
|
||||||
process.env.PI_AUTH_NO_BORROW = "1";
|
process.env.PI_AUTH_NO_BORROW = "1";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
/**
|
||||||
|
* OTP login flow tests derived from real captured request/response data.
|
||||||
|
* See scripts/debug-login-dump.json for the raw fixture.
|
||||||
|
*/
|
||||||
|
import { afterEach, describe, expect, mock, test } from "bun:test";
|
||||||
|
|
||||||
|
import { AuthError, type StoredToken } from "../../src/search/types.js";
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalBorrow = process.env.PI_AUTH_NO_BORROW;
|
||||||
|
const originalEmail = process.env.PI_PERPLEXITY_EMAIL;
|
||||||
|
const originalOtp = process.env.PI_PERPLEXITY_OTP;
|
||||||
|
|
||||||
|
// --- Fixtures from real Perplexity responses (scripts/debug-login-dump.json) ---
|
||||||
|
|
||||||
|
/** Real JWE token structure: alg=dir, enc=A256GCM — NOT a JWT, opaque to us */
|
||||||
|
const REAL_JWE_TOKEN =
|
||||||
|
"eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..AAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAA";
|
||||||
|
|
||||||
|
const CSRF_TOKEN = "0e4f8cc491e3197788492604ad32577f2022747fe30e0f51ba3ba235f07cc9ee";
|
||||||
|
|
||||||
|
const TEST_EMAIL = "user@test.com";
|
||||||
|
const TEST_OTP = "9f3e2-knzol";
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
async function importLoginModule() {
|
||||||
|
return import(`../../src/auth/login.ts?test=${crypto.randomUUID()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreEnv(): void {
|
||||||
|
for (const [key, original] of [
|
||||||
|
["PI_AUTH_NO_BORROW", originalBorrow],
|
||||||
|
["PI_PERPLEXITY_EMAIL", originalEmail],
|
||||||
|
["PI_PERPLEXITY_OTP", originalOtp],
|
||||||
|
] as const) {
|
||||||
|
if (original === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
} else {
|
||||||
|
process.env[key] = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockStorage() {
|
||||||
|
const loadTokenMock = mock(async () => null);
|
||||||
|
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
|
||||||
|
const clearTokenMock = mock(async () => undefined);
|
||||||
|
|
||||||
|
mock.module("../../src/auth/storage.js", () => ({
|
||||||
|
loadToken: loadTokenMock,
|
||||||
|
saveToken: saveTokenMock,
|
||||||
|
clearToken: clearTokenMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { loadTokenMock, saveTokenMock, clearTokenMock };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a fetch mock that replays real Perplexity response shapes. */
|
||||||
|
function buildReplayFetchMock(options?: {
|
||||||
|
/** Override the OTP response body (default: real token+status response) */
|
||||||
|
otpResponseBody?: unknown;
|
||||||
|
}) {
|
||||||
|
const calls: { url: string; init?: RequestInit }[] = [];
|
||||||
|
|
||||||
|
const otpBody = options?.otpResponseBody ?? { token: REAL_JWE_TOKEN, status: "success" };
|
||||||
|
|
||||||
|
const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
const entry: { url: string; init?: RequestInit } = { url };
|
||||||
|
if (init !== undefined) entry.init = init;
|
||||||
|
calls.push(entry);
|
||||||
|
|
||||||
|
if (url.endsWith("/csrf")) {
|
||||||
|
return new Response(JSON.stringify({ csrfToken: CSRF_TOKEN }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.endsWith("/signin-email")) {
|
||||||
|
return new Response(JSON.stringify({ success: "Email sign in triggered" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.endsWith("/signin-otp")) {
|
||||||
|
return new Response(JSON.stringify(otpBody), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { fetchMock, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
restoreEnv();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OTP login flow (from real captured responses)", () => {
|
||||||
|
test("full flow: CSRF → email → OTP, extracts JWE token from response body", async () => {
|
||||||
|
process.env.PI_AUTH_NO_BORROW = "1";
|
||||||
|
|
||||||
|
const { saveTokenMock } = mockStorage();
|
||||||
|
const { fetchMock } = buildReplayFetchMock();
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { authenticate } = await importLoginModule();
|
||||||
|
|
||||||
|
const token = await authenticate({
|
||||||
|
promptForEmail: async () => TEST_EMAIL,
|
||||||
|
promptForOtp: async () => TEST_OTP,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(token).toBe(REAL_JWE_TOKEN);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
expect(saveTokenMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const saved = saveTokenMock.mock.calls[0]?.[0] as StoredToken;
|
||||||
|
expect(saved.type).toBe("oauth");
|
||||||
|
expect(saved.access).toBe(REAL_JWE_TOKEN);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exactly 3 requests: no /session fallback when token is in body", async () => {
|
||||||
|
process.env.PI_AUTH_NO_BORROW = "1";
|
||||||
|
|
||||||
|
mockStorage();
|
||||||
|
const { fetchMock, calls } = buildReplayFetchMock();
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { authenticate } = await importLoginModule();
|
||||||
|
|
||||||
|
await authenticate({
|
||||||
|
promptForEmail: async () => TEST_EMAIL,
|
||||||
|
promptForOtp: async () => TEST_OTP,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(3);
|
||||||
|
expect(calls[0].url).toContain("/csrf");
|
||||||
|
expect(calls[1].url).toContain("/signin-email");
|
||||||
|
expect(calls[2].url).toContain("/signin-otp");
|
||||||
|
});
|
||||||
|
test("request bodies match expected shape", async () => {
|
||||||
|
process.env.PI_AUTH_NO_BORROW = "1";
|
||||||
|
|
||||||
|
mockStorage();
|
||||||
|
const { fetchMock, calls } = buildReplayFetchMock();
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { authenticate } = await importLoginModule();
|
||||||
|
|
||||||
|
await authenticate({
|
||||||
|
promptForEmail: async () => TEST_EMAIL,
|
||||||
|
promptForOtp: async () => TEST_OTP,
|
||||||
|
});
|
||||||
|
|
||||||
|
// CSRF is GET, no body
|
||||||
|
expect(calls[0].init?.method ?? "GET").toBe("GET");
|
||||||
|
expect(calls[0].init?.body).toBeFalsy();
|
||||||
|
|
||||||
|
// signin-email: POST with email + csrfToken
|
||||||
|
expect(calls[1].init?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(String(calls[1].init?.body))).toEqual({
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
csrfToken: CSRF_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
// signin-otp: POST with email + otp + csrfToken
|
||||||
|
expect(calls[2].init?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(String(calls[2].init?.body))).toEqual({
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
otp: TEST_OTP,
|
||||||
|
csrfToken: CSRF_TOKEN,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("env vars PI_PERPLEXITY_EMAIL and PI_PERPLEXITY_OTP bypass prompts", async () => {
|
||||||
|
process.env.PI_AUTH_NO_BORROW = "1";
|
||||||
|
process.env.PI_PERPLEXITY_EMAIL = TEST_EMAIL;
|
||||||
|
process.env.PI_PERPLEXITY_OTP = TEST_OTP;
|
||||||
|
|
||||||
|
mockStorage();
|
||||||
|
const { fetchMock } = buildReplayFetchMock();
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { authenticate } = await importLoginModule();
|
||||||
|
|
||||||
|
const promptForEmail = mock(async () => "should-not-be-called@test.com");
|
||||||
|
const promptForOtp = mock(async () => "should-not-be-called");
|
||||||
|
|
||||||
|
const token = await authenticate({ promptForEmail, promptForOtp });
|
||||||
|
|
||||||
|
expect(token).toBe(REAL_JWE_TOKEN);
|
||||||
|
expect(promptForEmail).toHaveBeenCalledTimes(0);
|
||||||
|
expect(promptForOtp).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws AuthError NO_TOKEN when email prompt returns undefined", async () => {
|
||||||
|
process.env.PI_AUTH_NO_BORROW = "1";
|
||||||
|
|
||||||
|
mockStorage();
|
||||||
|
|
||||||
|
const { authenticate } = await importLoginModule();
|
||||||
|
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
await authenticate({
|
||||||
|
promptForEmail: async () => undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(thrown).toBeInstanceOf(AuthError);
|
||||||
|
expect((thrown as AuthError).code).toBe("NO_TOKEN");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws AuthError NO_TOKEN when OTP prompt returns undefined", async () => {
|
||||||
|
process.env.PI_AUTH_NO_BORROW = "1";
|
||||||
|
|
||||||
|
mockStorage();
|
||||||
|
const { fetchMock } = buildReplayFetchMock();
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { authenticate } = await importLoginModule();
|
||||||
|
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
await authenticate({
|
||||||
|
promptForEmail: async () => TEST_EMAIL,
|
||||||
|
promptForOtp: async () => undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(thrown).toBeInstanceOf(AuthError);
|
||||||
|
expect((thrown as AuthError).code).toBe("NO_TOKEN");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when OTP verification returns non-200", async () => {
|
||||||
|
process.env.PI_AUTH_NO_BORROW = "1";
|
||||||
|
|
||||||
|
mockStorage();
|
||||||
|
|
||||||
|
const fetchMock = mock(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
|
||||||
|
if (url.endsWith("/csrf")) {
|
||||||
|
return new Response(JSON.stringify({ csrfToken: CSRF_TOKEN }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.endsWith("/signin-email")) {
|
||||||
|
return new Response(JSON.stringify({ success: "Email sign in triggered" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.endsWith("/signin-otp")) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { authenticate } = await importLoginModule();
|
||||||
|
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
await authenticate({
|
||||||
|
promptForEmail: async () => TEST_EMAIL,
|
||||||
|
promptForOtp: async () => TEST_OTP,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(thrown).toBeInstanceOf(AuthError);
|
||||||
|
expect((thrown as AuthError).code).toBe("EXTRACTION_FAILED");
|
||||||
|
expect((thrown as AuthError).message).toContain("OTP verification failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,8 +3,6 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|||||||
import { searchPerplexity } from "../../src/search/client.js";
|
import { searchPerplexity } from "../../src/search/client.js";
|
||||||
import { SearchError } from "../../src/search/types.js";
|
import { SearchError } from "../../src/search/types.js";
|
||||||
|
|
||||||
const ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask";
|
|
||||||
|
|
||||||
function createSseResponse(events: Array<Record<string, unknown>>, status = 200): Response {
|
function createSseResponse(events: Array<Record<string, unknown>>, status = 200): Response {
|
||||||
const streamText = [
|
const streamText = [
|
||||||
...events.map((event) => `data: ${JSON.stringify(event)}\n\n`),
|
...events.map((event) => `data: ${JSON.stringify(event)}\n\n`),
|
||||||
@@ -13,9 +11,7 @@ function createSseResponse(events: Array<Record<string, unknown>>, status = 200)
|
|||||||
|
|
||||||
return new Response(streamText, {
|
return new Response(streamText, {
|
||||||
status,
|
status,
|
||||||
headers: {
|
headers: { "content-type": "text/event-stream" },
|
||||||
"content-type": "text/event-stream",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,22 +35,12 @@ describe("searchPerplexity", () => {
|
|||||||
status: "COMPLETED",
|
status: "COMPLETED",
|
||||||
final: true,
|
final: true,
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{ intended_usage: "markdown_block", markdown_block: { answer: "answer text" } },
|
||||||
intended_usage: "markdown_block",
|
|
||||||
markdown_block: {
|
|
||||||
answer: "answer text",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
intended_usage: "web_results",
|
intended_usage: "web_results",
|
||||||
web_result_block: {
|
web_result_block: {
|
||||||
web_results: [
|
web_results: [
|
||||||
{
|
{ name: "Source", url: "https://example.com", snippet: "snippet", timestamp: "2026-02-16T10:00:00.000Z" },
|
||||||
name: "Source",
|
|
||||||
url: "https://example.com",
|
|
||||||
snippet: "snippet",
|
|
||||||
timestamp: "2026-02-16T10:00:00.000Z",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -70,7 +56,7 @@ describe("searchPerplexity", () => {
|
|||||||
controller.signal,
|
controller.signal,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(String(capturedUrl)).toBe(ENDPOINT);
|
expect(String(capturedUrl)).toBe("https://www.perplexity.ai/rest/sse/perplexity_ask");
|
||||||
expect(capturedInit?.method).toBe("POST");
|
expect(capturedInit?.method).toBe("POST");
|
||||||
expect(capturedInit?.signal).toBe(controller.signal);
|
expect(capturedInit?.signal).toBe(controller.signal);
|
||||||
|
|
||||||
@@ -133,12 +119,7 @@ describe("searchPerplexity", () => {
|
|||||||
status: "COMPLETED",
|
status: "COMPLETED",
|
||||||
final: true,
|
final: true,
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{ intended_usage: "markdown_block", markdown_block: { answer: "answer text" } },
|
||||||
intended_usage: "markdown_block",
|
|
||||||
markdown_block: {
|
|
||||||
answer: "answer text",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
intended_usage: "web_results",
|
intended_usage: "web_results",
|
||||||
web_result_block: {
|
web_result_block: {
|
||||||
@@ -168,14 +149,8 @@ describe("searchPerplexity", () => {
|
|||||||
final: true,
|
final: true,
|
||||||
text: "fallback text",
|
text: "fallback text",
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{ intended_usage: "ask_text", markdown_block: { answer: "ask text" } },
|
||||||
intended_usage: "ask_text",
|
{ intended_usage: "markdown_block", markdown_block: { answer: "markdown answer" } },
|
||||||
markdown_block: { answer: "ask text" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
intended_usage: "markdown_block",
|
|
||||||
markdown_block: { answer: "markdown answer" },
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
sources_list: [{ title: "S", url: "https://example.com" }],
|
sources_list: [{ title: "S", url: "https://example.com" }],
|
||||||
},
|
},
|
||||||
@@ -193,10 +168,7 @@ describe("searchPerplexity", () => {
|
|||||||
final: true,
|
final: true,
|
||||||
text: "fallback text",
|
text: "fallback text",
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{ intended_usage: "ask_text", markdown_block: { answer: "ask answer" } },
|
||||||
intended_usage: "ask_text",
|
|
||||||
markdown_block: { answer: "ask answer" },
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
sources_list: [{ title: "S", url: "https://example.com" }],
|
sources_list: [{ title: "S", url: "https://example.com" }],
|
||||||
},
|
},
|
||||||
@@ -221,12 +193,7 @@ describe("searchPerplexity", () => {
|
|||||||
|
|
||||||
test("returns EMPTY error when response has no answer and no sources", async () => {
|
test("returns EMPTY error when response has no answer and no sources", async () => {
|
||||||
globalThis.fetch = (async () =>
|
globalThis.fetch = (async () =>
|
||||||
createSseResponse([
|
createSseResponse([{ status: "COMPLETED", final: true }])) as unknown as typeof fetch;
|
||||||
{
|
|
||||||
status: "COMPLETED",
|
|
||||||
final: true,
|
|
||||||
},
|
|
||||||
])) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
let thrown: unknown;
|
let thrown: unknown;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
|||||||
Reference in New Issue
Block a user