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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 50 additions & 6 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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 —
Expand Down
34 changes: 34 additions & 0 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,37 @@ export function cleanThreadiverseParams<P extends Record<string, unknown>>(
export function toLowerCase<T extends string>(type: T): Lowercase<T> {
return type.toLowerCase() as Lowercase<T>;
}

/**
* 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<string, string> | undefined,
allowed: string[],
): Record<string, string> | 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;
}
81 changes: 50 additions & 31 deletions src/providers/piefed/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 = {
Expand All @@ -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,
});

Expand Down
44 changes: 40 additions & 4 deletions src/wellknown.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -38,18 +43,22 @@ export async function resolveSoftware(
): Promise<Nodeinfo21Payload["software"]> {
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),
},
};

const response = await fetch(`${url}/.well-known/nodeinfo`, fetchOptions);

const data = parseDiscoveryPayload(
NodeinfoLinksPayload,
await response.json(),
await parseDiscoveryJson(response, "nodeinfo links"),
"nodeinfo links",
);

Expand All @@ -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<unknown> {
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
Expand Down
22 changes: 22 additions & 0 deletions test/ThreadiverseClient.connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Loading