diff --git a/src/errors.ts b/src/errors.ts
index fb40f43..5727d3b 100644
--- a/src/errors.ts
+++ b/src/errors.ts
@@ -2,6 +2,8 @@ import type { LemmyErrorType } from "lemmy-js-client-v0";
import { PiefedErrorResponse } from "./types";
+export type BotChallengeVendor = "anubis" | "cloudflare";
+
/**
* Machine-readable error codes a fediverse server may return (e.g.
* "incorrect_login", "too_many_requests"), exposed on `ResponseError.code`.
@@ -67,12 +69,6 @@ export class ResponseError extends FediverseError {
}
}
-// ---------------------------------------------------------------------------
-// Condition subclasses. Add a class (and its code mapping in
-// CONDITION_BY_CODE) when a consumer needs to branch on a condition — the
-// live error-fidelity suite verifies mappings against real instances.
-// ---------------------------------------------------------------------------
-
/** The account has been deleted */
export class AccountDeletedError extends ResponseError {
constructor(code: string, options?: ResponseErrorOptions) {
@@ -81,6 +77,12 @@ export class AccountDeletedError extends ResponseError {
}
}
+// ---------------------------------------------------------------------------
+// Condition subclasses. Add a class (and its code mapping in
+// CONDITION_BY_CODE) when a consumer needs to branch on a condition — the
+// live error-fidelity suite verifies mappings against real instances.
+// ---------------------------------------------------------------------------
+
/** The account is banned from the instance */
export class BannedError extends ResponseError {
constructor(code: string, options?: ResponseErrorOptions) {
@@ -89,6 +91,26 @@ export class BannedError extends ResponseError {
}
}
+/**
+ * The request was intercepted by the instance's bot protection (e.g. a
+ * Cloudflare "Just a moment..." challenge) and never reached the fediverse
+ * software. Typically means the request's fingerprint looked automated —
+ * e.g. a native app sending a browser User-Agent over a non-browser TLS
+ * stack. Which protection intercepted it is on `.vendor`.
+ */
+export class BotChallengeError extends FediverseError {
+ vendor: BotChallengeVendor;
+
+ constructor(vendor: BotChallengeVendor, errorOptions?: ErrorOptions) {
+ super(
+ `Blocked by the instance's bot protection challenge (${vendor})`,
+ errorOptions,
+ );
+ this.name = "BotChallengeError";
+ this.vendor = vendor;
+ }
+}
+
/** Admins cannot be blocked */
export class CantBlockAdminError extends ResponseError {
constructor(code: string, options?: ResponseErrorOptions) {
@@ -192,6 +214,28 @@ export class UnsupportedSoftwareError extends UnsupportedError {
}
}
+/**
+ * Which bot protection's challenge interstitial `response` is, or
+ * `undefined` if it doesn't look like one and presumably came from the
+ * fediverse software itself.
+ *
+ * Cloudflare documents `cf-mitigated: challenge`. Anubis has no documented
+ * detection contract, so markers are empirical (verified against live
+ * v1.25/v1.26 deployments; the live-smoke suite detects drift): its
+ * `*anubis*` cookies (readable on native and server runtimes; browsers hide
+ * Set-Cookie, but real browsers pass challenges anyway) and its
+ * `/.within.website/` vendor asset namespace in the challenge HTML (`body` —
+ * already-consumed text).
+ */
+export function detectBotChallenge(
+ response: Response,
+ body?: string,
+): BotChallengeVendor | undefined {
+ if (response.headers.get("cf-mitigated") === "challenge") return "cloudflare";
+ if (response.headers.get("set-cookie")?.includes("anubis")) return "anubis";
+ if (body?.includes("/.within.website/")) return "anubis";
+}
+
// Lemmy codes (v0 + v1) plus PieFed's native codes as observed by the live
// error-fidelity suite (PieFed puts human-ish prose in its message field;
// the exact strings below were captured from piefed.social 2026-07-02 —
diff --git a/src/helpers.ts b/src/helpers.ts
index da39a2c..4e35174 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -14,3 +14,37 @@ export function cleanThreadiverseParams
>(
export function toLowerCase(type: T): Lowercase {
return type.toLowerCase() as Lowercase;
}
+
+/**
+ * User-Agent and Capacitor's Android alias for it. The Android webview
+ * strips `User-Agent` once headers enter a `Request` object
+ * (https://issues.chromium.org/issues/40450316), so Capacitor apps send
+ * `x-cap-user-agent` instead (converted back to `User-Agent` natively). It
+ * only appears on native, where CORS doesn't apply.
+ *
+ * Forwarding the user agent matters: instances behind bot protection (e.g.
+ * piefed.social's Cloudflare) challenge requests that arrive with a browser
+ * User-Agent over a non-browser network stack.
+ */
+export const USER_AGENT_HEADERS = ["User-Agent", "x-cap-user-agent"];
+
+/**
+ * Pick `allowed` headers (case-insensitively) from `headers`. Used where
+ * headers can't be forwarded wholesale — e.g. piefed's CORS policy only
+ * allows `Content-Type, Authorization, Accept, User-Agent`, so forwarding
+ * anything else (like `Cache-Control`) fails preflight in browsers.
+ */
+export function pickHeaders(
+ headers: Record | undefined,
+ allowed: string[],
+): Record | undefined {
+ if (!headers) return;
+
+ const picked = Object.fromEntries(
+ Object.entries(headers).filter(([key]) =>
+ allowed.some((allow) => allow.toLowerCase() === key.toLowerCase()),
+ ),
+ );
+
+ if (Object.keys(picked).length) return picked;
+}
diff --git a/src/providers/piefed/index.ts b/src/providers/piefed/index.ts
index 6aba183..a2702a6 100644
--- a/src/providers/piefed/index.ts
+++ b/src/providers/piefed/index.ts
@@ -6,10 +6,13 @@ import {
RequestOptions,
} from "../../BaseClient";
import {
+ BotChallengeError,
createResponseError,
+ detectBotChallenge,
InvalidPayloadError,
UnsupportedError,
} from "../../errors";
+import { pickHeaders, USER_AGENT_HEADERS } from "../../helpers";
import buildSafeClient from "../../SafeClient";
import { PiefedErrorResponse } from "../../schemas";
import * as types from "../../types";
@@ -23,33 +26,48 @@ import * as compat from "./compat";
import { components, paths } from "./schema";
async function validateResponse(response: Response) {
- if (!response.ok) {
- let code = response.statusText;
- let payload: types.PiefedErrorResponse | undefined;
- try {
- const data: unknown = await response.json();
- const parsed = PiefedErrorResponse.safeParse(data);
- if (parsed.success) {
- payload = parsed.data;
- code = parsed.data.message;
- } else if (
- data &&
- typeof data === "object" &&
- "error" in data &&
- typeof data.error === "string"
- ) {
- code = data.error;
- }
- } catch {
- // Non-JSON body (e.g., HTML error page from an upstream proxy).
- // Fall back to statusText already set above.
+ if (response.ok) {
+ // Bot challenges (e.g. Anubis) can interject HTML with a 2xx status.
+ // Clone so the caller can still consume the body.
+ if (response.headers.get("content-type")?.includes("text/html")) {
+ const body = await response.clone().text();
+ const vendor = detectBotChallenge(response, body);
+ if (vendor) throw new BotChallengeError(vendor);
}
- throw createResponseError(code, {
- response: payload,
- software: "piefed",
- status: response.status,
- });
+ return;
+ }
+
+ const headerVendor = detectBotChallenge(response);
+ if (headerVendor) throw new BotChallengeError(headerVendor);
+
+ let code = response.statusText;
+ let payload: types.PiefedErrorResponse | undefined;
+ const body = await response.text();
+ try {
+ const data: unknown = JSON.parse(body);
+ const parsed = PiefedErrorResponse.safeParse(data);
+ if (parsed.success) {
+ payload = parsed.data;
+ code = parsed.data.message;
+ } else if (
+ data &&
+ typeof data === "object" &&
+ "error" in data &&
+ typeof data.error === "string"
+ ) {
+ code = data.error;
+ }
+ } catch {
+ // Non-JSON body (e.g., HTML error page from an upstream proxy).
+ // Fall back to statusText already set above.
+ const vendor = detectBotChallenge(response, body);
+ if (vendor) throw new BotChallengeError(vendor);
}
+ throw createResponseError(code, {
+ response: payload,
+ software: "piefed",
+ status: response.status,
+ });
}
const piefedMiddleware: Middleware = {
@@ -76,18 +94,19 @@ export class UnsafePiefedClient implements BaseClient {
this.#customFetch = options.fetchFunction ?? globalThis.fetch;
this.#url = url;
- const headers = options.headers?.Authorization
- ? {
- Authorization: options.headers?.Authorization,
- }
- : undefined;
+ // Piefed's CORS policy only allows `Content-Type, Authorization, Accept,
+ // User-Agent` — forwarding anything else (e.g. `Cache-Control`) fails
+ // preflight in browsers.
+ const headers = pickHeaders(options.headers, [
+ "Authorization",
+ ...USER_AGENT_HEADERS,
+ ]);
this.#headers = headers;
this.#client = createClient({
baseUrl: url,
fetch: options.fetchFunction,
- // TODO: piefed doesn't allow CORS headers other than Authorization
headers,
});
diff --git a/src/wellknown.ts b/src/wellknown.ts
index a72f2d4..328dde4 100644
--- a/src/wellknown.ts
+++ b/src/wellknown.ts
@@ -1,7 +1,12 @@
import { z } from "zod/v4-mini";
import { BaseClientOptions } from "./BaseClient";
-import { UnexpectedResponseError } from "./errors";
+import {
+ BotChallengeError,
+ detectBotChallenge,
+ UnexpectedResponseError,
+} from "./errors";
+import { pickHeaders, USER_AGENT_HEADERS } from "./helpers";
export const NodeinfoLink = z.object({
href: z.string(),
@@ -38,10 +43,14 @@ export async function resolveSoftware(
): Promise {
const fetch = options?.fetchFunction ?? globalThis.fetch;
+ // Discovery hits arbitrary instances, so only forward headers that are
+ // universally CORS-safe (see USER_AGENT_HEADERS for why the user agent
+ // must be forwarded). Notably not Authorization — no reason to send
+ // credentials to nodeinfo endpoints.
const fetchOptions: RequestInit = {
headers: {
- // ...options?.headers, // TODO: Piefed doesn't allow many headers for CORS
Accept: "application/json",
+ ...pickHeaders(options?.headers, USER_AGENT_HEADERS),
},
};
@@ -49,7 +58,7 @@ export async function resolveSoftware(
const data = parseDiscoveryPayload(
NodeinfoLinksPayload,
- await response.json(),
+ await parseDiscoveryJson(response, "nodeinfo links"),
"nodeinfo links",
);
@@ -62,13 +71,40 @@ export async function resolveSoftware(
const nodeinfoData = parseDiscoveryPayload(
Nodeinfo21Payload,
- await nodeinfoResponse.json(),
+ await parseDiscoveryJson(nodeinfoResponse, "nodeinfo"),
"nodeinfo",
);
return nodeinfoData.software;
}
+/**
+ * Non-JSON discovery responses get a diagnosis instead of a raw
+ * `SyntaxError`: a bot-protection interstitial (e.g. Cloudflare's
+ * "Just a moment..." HTML page) throws `BotChallengeError` so consumers can
+ * explain what actually blocked the connection.
+ */
+async function parseDiscoveryJson(
+ response: Response,
+ what: string,
+): Promise {
+ const headerVendor = detectBotChallenge(response);
+ if (headerVendor) throw new BotChallengeError(headerVendor);
+
+ const body = await response.text();
+
+ try {
+ return JSON.parse(body);
+ } catch (error) {
+ const vendor = detectBotChallenge(response, body);
+ if (vendor) throw new BotChallengeError(vendor);
+
+ throw new UnexpectedResponseError(`Non-JSON ${what} response`, {
+ cause: error,
+ });
+ }
+}
+
/**
* Discovery is the "is this even a supported fediverse instance?" boundary,
* so malformed payloads surface as the library's error taxonomy (with the
diff --git a/test/ThreadiverseClient.connect.test.ts b/test/ThreadiverseClient.connect.test.ts
index cdf78cc..e50f6ca 100644
--- a/test/ThreadiverseClient.connect.test.ts
+++ b/test/ThreadiverseClient.connect.test.ts
@@ -34,6 +34,28 @@ describe("connect() and sync introspection", () => {
expect(fake.calls("GET /.well-known/nodeinfo")).toHaveLength(1);
});
+ it("forwards User-Agent (and not credentials) to discovery requests", async () => {
+ const fake = new FakeLemmyV1Instance();
+ const client = new ThreadiverseClient(fake.origin, {
+ ...fake.clientOptions(),
+ headers: {
+ Authorization: "Bearer abc",
+ "User-Agent": "VoyagerApp/1.0",
+ },
+ });
+
+ await client.connect();
+
+ for (const matcher of [
+ "GET /.well-known/nodeinfo",
+ "GET /nodeinfo/2.1",
+ ] as const) {
+ const [call] = fake.calls(matcher);
+ expect(call?.headers).toMatchObject({ "user-agent": "VoyagerApp/1.0" });
+ expect(call?.headers).not.toHaveProperty("authorization");
+ }
+ });
+
it("any API call connects implicitly", async () => {
const fake = new FakeLemmyV1Instance();
const client = new ThreadiverseClient(fake.origin, fake.clientOptions());
diff --git a/test/bot-challenge-and-headers.test.ts b/test/bot-challenge-and-headers.test.ts
new file mode 100644
index 0000000..dee4672
--- /dev/null
+++ b/test/bot-challenge-and-headers.test.ts
@@ -0,0 +1,197 @@
+// Instances behind bot protection (e.g. piefed.social's Cloudflare)
+// challenge requests that don't carry the app's User-Agent — and Capacitor
+// Android apps must send it as x-cap-user-agent to survive the webview
+// (see USER_AGENT_HEADERS). These tests pin the two behaviors that keep
+// that working: header forwarding on every piefed code path, and challenge
+// responses surfacing as BotChallengeError instead of raw JSON parse errors.
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { BaseClientOptions } from "../src/BaseClient";
+import { UnexpectedResponseError } from "../src/errors";
+import ThreadiverseClient, { clearCache } from "../src/ThreadiverseClient";
+
+const BASE_URL = "https://piefed.example.com";
+const NODEINFO_HREF = `${BASE_URL}/nodeinfo/2.1`;
+
+const CLIENT_HEADERS = {
+ Authorization: "Bearer abc",
+ ["Cache-Control"]: "no-cache",
+ ["User-Agent"]: "VoyagerApp/1.0",
+ ["x-cap-user-agent"]: "VoyagerApp/1.0",
+};
+
+const CLOUDFLARE_CHALLENGE = new Response(
+ "Just a moment...",
+ {
+ headers: { "cf-mitigated": "challenge", "Content-Type": "text/html" },
+ status: 403,
+ },
+);
+
+const ANUBIS_CHALLENGE = new Response(
+ '',
+ { headers: { "Content-Type": "text/html" }, status: 200 },
+);
+
+function json(payload: unknown) {
+ return new Response(JSON.stringify(payload), {
+ headers: { "Content-Type": "application/json" },
+ status: 200,
+ });
+}
+
+const NODEINFO_LINKS = {
+ links: [
+ {
+ href: NODEINFO_HREF,
+ rel: "http://nodeinfo.diaspora.software/ns/schema/2.1",
+ },
+ ],
+};
+
+const NODEINFO = { software: { name: "piefed", version: "1.6.27" } };
+
+const SITE = {
+ admins: [],
+ site: {
+ actor_id: `${BASE_URL}/`,
+ name: "Test",
+ registration_mode: "Open",
+ user_count: 100,
+ },
+ version: "1.6.27",
+};
+
+/**
+ * Client against a piefed instance where every route can be overridden.
+ * Records the headers of each request by pathname.
+ */
+function setup(overrides: Record Response> = {}) {
+ const requestHeaders: Record = {};
+
+ const fetchFunction = vi.fn(
+ async (input: RequestInfo | URL, init?: RequestInit) => {
+ const request = new Request(input, init);
+ const { pathname } = new URL(request.url);
+
+ requestHeaders[pathname] = request.headers;
+
+ const override = overrides[pathname];
+ if (override) return override();
+
+ switch (pathname) {
+ case "/.well-known/nodeinfo":
+ return json(NODEINFO_LINKS);
+ case "/api/alpha/site":
+ return json(SITE);
+ case "/nodeinfo/2.1":
+ return json(NODEINFO);
+ default:
+ throw new Error(`Unexpected fetch call: ${request.url}`);
+ }
+ },
+ ) as BaseClientOptions["fetchFunction"];
+
+ const client = new ThreadiverseClient(BASE_URL, {
+ fetchFunction,
+ headers: CLIENT_HEADERS,
+ });
+
+ return { client, requestHeaders };
+}
+
+beforeEach(() => {
+ clearCache();
+});
+
+describe("piefed header forwarding", () => {
+ it("forwards the user agent (but not credentials) to discovery", async () => {
+ const { client, requestHeaders } = setup();
+
+ await client.connect();
+
+ for (const pathname of ["/.well-known/nodeinfo", "/nodeinfo/2.1"]) {
+ const headers = requestHeaders[pathname]!;
+ expect(headers.get("user-agent")).toBe("VoyagerApp/1.0");
+ expect(headers.get("x-cap-user-agent")).toBe("VoyagerApp/1.0");
+ expect(headers.get("authorization")).toBeNull();
+ expect(headers.get("cache-control")).toBeNull();
+ }
+ });
+
+ it("forwards user agent + Authorization (but nothing else) to API calls", async () => {
+ const { client, requestHeaders } = setup();
+
+ await client.getSite();
+
+ const headers = requestHeaders["/api/alpha/site"]!;
+ expect(headers.get("user-agent")).toBe("VoyagerApp/1.0");
+ expect(headers.get("x-cap-user-agent")).toBe("VoyagerApp/1.0");
+ expect(headers.get("authorization")).toBe("Bearer abc");
+ // Not in piefed's Access-Control-Allow-Headers — would fail preflight
+ expect(headers.get("cache-control")).toBeNull();
+ });
+});
+
+describe("bot challenge detection", () => {
+ it("Cloudflare challenge during discovery throws BotChallengeError", async () => {
+ const { client } = setup({
+ "/.well-known/nodeinfo": () => CLOUDFLARE_CHALLENGE.clone(),
+ });
+
+ await expect(client.connect()).rejects.toThrow(
+ expect.objectContaining({
+ name: "BotChallengeError",
+ vendor: "cloudflare",
+ }),
+ );
+ });
+
+ it("Anubis challenge (2xx HTML) during discovery throws BotChallengeError", async () => {
+ const { client } = setup({
+ "/.well-known/nodeinfo": () => ANUBIS_CHALLENGE.clone(),
+ });
+
+ await expect(client.connect()).rejects.toThrow(
+ expect.objectContaining({ name: "BotChallengeError", vendor: "anubis" }),
+ );
+ });
+
+ it("Cloudflare challenge on a piefed API call throws BotChallengeError", async () => {
+ const { client } = setup({
+ "/api/alpha/site": () => CLOUDFLARE_CHALLENGE.clone(),
+ });
+
+ await expect(client.getSite()).rejects.toThrow(
+ expect.objectContaining({
+ name: "BotChallengeError",
+ vendor: "cloudflare",
+ }),
+ );
+ });
+
+ it("Anubis challenge (2xx HTML) on a piefed API call throws BotChallengeError", async () => {
+ const { client } = setup({
+ "/api/alpha/site": () => ANUBIS_CHALLENGE.clone(),
+ });
+
+ await expect(client.getSite()).rejects.toThrow(
+ expect.objectContaining({ name: "BotChallengeError", vendor: "anubis" }),
+ );
+ });
+
+ it("other non-JSON discovery responses throw UnexpectedResponseError", async () => {
+ const { client } = setup({
+ "/.well-known/nodeinfo": () =>
+ new Response("not a fediverse instance", {
+ headers: { "Content-Type": "text/html" },
+ status: 200,
+ }),
+ });
+
+ await expect(client.connect()).rejects.toBeInstanceOf(
+ UnexpectedResponseError,
+ );
+ });
+});
diff --git a/test/live-smoke.test.ts b/test/live-smoke.test.ts
index b91503d..985cf8f 100644
--- a/test/live-smoke.test.ts
+++ b/test/live-smoke.test.ts
@@ -7,6 +7,7 @@
import { describe, expect, it } from "vitest";
+import { detectBotChallenge } from "../src/errors";
import ThreadiverseClient from "../src/ThreadiverseClient";
const INSTANCES = [
@@ -68,3 +69,20 @@ describe.runIf(process.env.LIVE_SMOKE)("live smoke", () => {
});
});
});
+
+// Bot-challenge detection markers are empirical (Anubis documents no
+// contract and has renamed its cookies before) — verify they still match a
+// real deployment. Requests a page with a browser-ish User-Agent, which
+// Anubis challenges by default.
+describe.runIf(process.env.LIVE_SMOKE)("anubis challenge detection", () => {
+ it("recognizes a live anubis challenge", OPTIONS, async () => {
+ const response = await fetch("https://xeiaso.net/", {
+ headers: {
+ // Node lets fetch override User-Agent (unlike browsers)
+ ["User-Agent"]: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
+ },
+ });
+
+ expect(detectBotChallenge(response, await response.text())).toBe("anubis");
+ });
+});