Skip to content

Commit e025ab4

Browse files
adjsut for brave
1 parent fa38e8c commit e025ab4

5 files changed

Lines changed: 173 additions & 20 deletions

File tree

src/app/api/node/route.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
type CreatedNode,
1313
} from "@/db/graph";
1414
import { newCode } from "@/lib/codes";
15-
import { classify, isHuman } from "@/lib/classify";
15+
import { isHuman, resolveClass } from "@/lib/classify";
1616
import { geoFromHeaders, clientIp } from "@/lib/geo";
1717
import { hashKeyed } from "@/lib/crypto";
1818
import { logApiError, logApiReject, zodIssueSummary } from "@/lib/api-log";
@@ -28,6 +28,7 @@ const Body = z.object({
2828
ref: z.string().min(4).max(16).optional(), // referrer share code
2929
incognito: z.boolean().optional(),
3030
botd: z.boolean().optional(), // @fingerprintjs/botd client verdict
31+
privacyBrowser: z.boolean().optional(), // Brave etc. — softens lone BotID false positives
3132
referer: z.string().max(512).optional(), // document.referrer (client-observed)
3233
src: z.string().max(64).optional(), // share-channel tag from ?src=
3334
});
@@ -47,7 +48,7 @@ export async function POST(req: NextRequest) {
4748
return NextResponse.json({ error: "identity_mismatch" }, { status: 403 });
4849
}
4950

50-
const { localId, fingerprint, ref, incognito, botd, src } = parsed.data;
51+
const { localId, fingerprint, ref, incognito, botd, privacyBrowser, src } = parsed.data;
5152

5253
const h = req.headers;
5354
const ua = h.get("user-agent");
@@ -66,8 +67,13 @@ export async function POST(req: NextRequest) {
6667
/* detector unavailable — fall through to UA/botd */
6768
}
6869
}
69-
const nodeClass = classify({ ua, botdDetected: botd, botIdIsBot });
7070
const fpHash = fingerprint ? hashKeyed(fingerprint) : null;
71+
const ephemeral = !!incognito;
72+
const classified = resolveClass(
73+
{ ua, botdDetected: botd, botIdIsBot },
74+
{ hasFingerprint: !!fpHash, ephemeral, privacyBrowser: !!privacyBrowser },
75+
);
76+
const nodeClass = classified.class;
7177
const ipHash = hashKeyed(clientIp(h));
7278

7379
// ── Resolve referrer code → id (write-once edge source). ──
@@ -103,7 +109,6 @@ export async function POST(req: NextRequest) {
103109
}
104110

105111
const geo = geoFromHeaders(h);
106-
const ephemeral = !!incognito;
107112

108113
// ── Create node (single-statement ltree path + depth + cycle/depth guard). ──
109114
let created: CreatedNode;
@@ -149,11 +154,12 @@ export async function POST(req: NextRequest) {
149154
fingerprint: fpHash,
150155
ipHash,
151156
ua: ua ?? null,
157+
botidVerdict: classified.verdict,
152158
incognitoGuess: ephemeral,
153159
referer: refererHost(referer), // origin only — never store full URL/query (privacy)
154160
src: src ?? null,
155161
riskScore: riskScore({
156-
class: nodeClass,
162+
baseRisk: classified.baseRisk,
157163
ephemeral,
158164
hasFingerprint: !!fpHash,
159165
ipShared: false,

src/lib/classify.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, it, expect } from "vitest";
2+
import { resolveClass } from "./classify";
3+
4+
const CHROME_UA =
5+
"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Mobile Safari/537.36";
6+
const HUMAN_CTX = { hasFingerprint: true, ephemeral: false };
7+
8+
describe("resolveClass", () => {
9+
it("classifies clean Chrome mobile as human", () => {
10+
const r = resolveClass({ ua: CHROME_UA }, HUMAN_CTX);
11+
expect(r.class).toBe("human");
12+
expect(r.baseRisk).toBe(0);
13+
expect(r.verdict).toBe("human");
14+
});
15+
16+
it("hard-bots missing UA", () => {
17+
const r = resolveClass({ ua: null }, HUMAN_CTX);
18+
expect(r.class).toBe("bot");
19+
expect(r.verdict).toBe("no_ua");
20+
});
21+
22+
it("hard-bots known crawler UA", () => {
23+
const r = resolveClass(
24+
{ ua: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" },
25+
HUMAN_CTX,
26+
);
27+
expect(r.class).toBe("bot");
28+
expect(r.verdict).toBe("isbot_ua");
29+
});
30+
31+
it("hard-bots when BotID and BotD both fire", () => {
32+
const r = resolveClass({ ua: CHROME_UA, botIdIsBot: true, botdDetected: true }, HUMAN_CTX);
33+
expect(r.class).toBe("bot");
34+
expect(r.verdict).toBe("botid+botd");
35+
});
36+
37+
it("hard-bots lone detector without fingerprint", () => {
38+
const r = resolveClass(
39+
{ ua: CHROME_UA, botdDetected: true },
40+
{ hasFingerprint: false, ephemeral: false },
41+
);
42+
expect(r.class).toBe("bot");
43+
expect(r.verdict).toBe("botd_no_fp");
44+
});
45+
46+
it("overrides lone BotD when fingerprint present (Brave-style)", () => {
47+
const r = resolveClass({ ua: CHROME_UA, botdDetected: true }, HUMAN_CTX);
48+
expect(r.class).toBe("human");
49+
expect(r.baseRisk).toBe(40);
50+
expect(r.verdict).toBe("botd_override:human");
51+
});
52+
53+
it("overrides lone BotID on privacy browser with fingerprint", () => {
54+
const r = resolveClass(
55+
{ ua: CHROME_UA, botIdIsBot: true },
56+
{ ...HUMAN_CTX, privacyBrowser: true },
57+
);
58+
expect(r.class).toBe("human");
59+
expect(r.baseRisk).toBe(45);
60+
expect(r.verdict).toBe("botid_override:human");
61+
});
62+
63+
it("does not override lone BotID without privacy browser hint", () => {
64+
const r = resolveClass({ ua: CHROME_UA, botIdIsBot: true }, HUMAN_CTX);
65+
expect(r.class).toBe("bot");
66+
expect(r.verdict).toBe("botid_only");
67+
});
68+
69+
it("does not override BotD in incognito even with fingerprint", () => {
70+
const r = resolveClass(
71+
{ ua: CHROME_UA, botdDetected: true },
72+
{ hasFingerprint: true, ephemeral: true },
73+
);
74+
expect(r.class).toBe("bot");
75+
expect(r.verdict).toBe("botd_only");
76+
});
77+
});

src/lib/classify.ts

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,88 @@
33
* (bot collapses crawler/preview/automation — non-human nodes are excluded from
44
* metrics and dimmed/uncreated; we don't need to tell the sub-kinds apart.)
55
*
6-
* Layered, cheapest → strongest, stronger overrides weaker:
7-
* 1. isbot(ua) — maintained UA cull (cheap, runs first)
8-
* 2. botd verdict — client headless/automation signal (Puppeteer/etc.)
9-
* 3. Vercel BotID verdict — authoritative server signal, immune to UA spoofing
6+
* Tiered: hard-bot signals always win; a single BotD/BotID flag with strong human
7+
* context (fingerprint + not incognito) stays human with elevated risk — privacy
8+
* browsers like Brave often trip lone client detectors.
109
*/
1110
import { isbot } from "isbot";
1211

1312
export type NodeClass = "human" | "bot";
1413

15-
/** Verdicts gathered from stronger detectors (any true ⇒ bot). */
16-
export type BotSignals = {
14+
export type RawBotSignals = {
1715
ua: string | null;
1816
/** @fingerprintjs/botd client result: true if automation detected. */
1917
botdDetected?: boolean;
2018
/** Vercel BotID server verdict: true if classified a bot. */
2119
botIdIsBot?: boolean;
2220
};
2321

24-
export function classify(sig: BotSignals): NodeClass {
25-
if (sig.botIdIsBot) return "bot"; // authoritative
26-
if (sig.botdDetected) return "bot";
27-
if (!sig.ua) return "bot"; // no UA = scripted
28-
if (isbot(sig.ua)) return "bot";
29-
return "human";
22+
export type ClassifyContext = {
23+
hasFingerprint: boolean;
24+
ephemeral: boolean;
25+
/** Brave / other privacy-hardened browsers (client-reported). */
26+
privacyBrowser?: boolean;
27+
};
28+
29+
export type ClassifyResult = {
30+
class: NodeClass;
31+
/** Detector-tier risk before ephemeral/fingerprint/IP modifiers (risk.ts). */
32+
baseRisk: number;
33+
reasons: string[];
34+
/** Compact audit string stored on node_signals.botid_verdict */
35+
verdict: string;
36+
};
37+
38+
function hasHumanContext(ctx: ClassifyContext): boolean {
39+
return ctx.hasFingerprint && !ctx.ephemeral;
40+
}
41+
42+
export function resolveClass(sig: RawBotSignals, ctx: ClassifyContext): ClassifyResult {
43+
const botId = !!sig.botIdIsBot;
44+
const botd = !!sig.botdDetected;
45+
46+
if (!sig.ua) {
47+
return { class: "bot", baseRisk: 90, reasons: ["no_ua"], verdict: "no_ua" };
48+
}
49+
if (isbot(sig.ua)) {
50+
return { class: "bot", baseRisk: 80, reasons: ["isbot_ua"], verdict: "isbot_ua" };
51+
}
52+
53+
if (botId && botd) {
54+
return { class: "bot", baseRisk: 85, reasons: ["botid_and_botd"], verdict: "botid+botd" };
55+
}
56+
57+
if ((botId || botd) && !ctx.hasFingerprint) {
58+
const verdict = botId ? "botid_no_fp" : "botd_no_fp";
59+
return { class: "bot", baseRisk: 75, reasons: [verdict], verdict };
60+
}
61+
62+
const humanCtx = hasHumanContext(ctx);
63+
64+
if (botd && !botId && humanCtx) {
65+
return {
66+
class: "human",
67+
baseRisk: 40,
68+
reasons: ["botd_overridden"],
69+
verdict: "botd_override:human",
70+
};
71+
}
72+
73+
if (botId && !botd && humanCtx && ctx.privacyBrowser) {
74+
return {
75+
class: "human",
76+
baseRisk: 45,
77+
reasons: ["botid_privacy_override"],
78+
verdict: "botid_override:human",
79+
};
80+
}
81+
82+
if (botId || botd) {
83+
const verdict = botId ? "botid_only" : "botd_only";
84+
return { class: "bot", baseRisk: 70, reasons: [verdict], verdict };
85+
}
86+
87+
return { class: "human", baseRisk: 0, reasons: [], verdict: "human" };
3088
}
3189

3290
export function isHuman(c: NodeClass): boolean {

src/lib/client-identity.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,18 @@ function withTimeout<T>(p: Promise<T>, ms: number, fallback: T): Promise<T> {
9292
]);
9393
}
9494

95+
/** Brave and similar browsers farble fingerprint APIs — report for server soft overrides. */
96+
export function detectPrivacyBrowser(): boolean {
97+
if (typeof navigator === "undefined") return false;
98+
const nav = navigator as Navigator & { brave?: { isBrave?: () => boolean } };
99+
try {
100+
if (nav.brave?.isBrave?.()) return true;
101+
} catch {
102+
/* ignore */
103+
}
104+
return false;
105+
}
106+
95107
let botdPromise: Promise<boolean> | null = null;
96108
/** @fingerprintjs/botd headless/automation detection (client signal). */
97109
async function detectBot(): Promise<boolean> {
@@ -141,6 +153,7 @@ export async function registerNode(ref: string | null): Promise<NodeResponse | n
141153
ref: ref && ref.length >= 4 ? ref : undefined,
142154
incognito,
143155
botd,
156+
privacyBrowser: detectPrivacyBrowser() || undefined,
144157
referer,
145158
src: src && src.length > 0 ? src : undefined,
146159
};

src/lib/risk.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,14 @@
55
* trigger step-up challenges or leaderboard dampening (not a hard block here).
66
*/
77
export type RiskInput = {
8-
class: string; // from classify
8+
baseRisk: number; // from resolveClass detector tier
99
ephemeral: boolean; // incognito
1010
hasFingerprint: boolean;
1111
ipShared: boolean; // many distinct localIds on this IP recently (good signal!)
1212
};
1313

1414
export function riskScore(i: RiskInput): number {
15-
let s = 0;
16-
if (i.class !== "human") s += 70;
15+
let s = i.baseRisk;
1716
if (!i.hasFingerprint) s += 20;
1817
if (i.ephemeral) s += 10;
1918
// Shared IP with HIGH localId diversity = a crowd, LOWERS risk (DESIGN §6.6).

0 commit comments

Comments
 (0)