diff --git a/app/(app)/dashboard/projects/[id]/stats/declared-card.tsx b/app/(app)/dashboard/projects/[id]/stats/declared-card.tsx new file mode 100644 index 0000000..0594ae5 --- /dev/null +++ b/app/(app)/dashboard/projects/[id]/stats/declared-card.tsx @@ -0,0 +1,107 @@ +import Link from "next/link"; +import type { DeclaredSummary } from "@/lib/tracker/actorStore"; + +// Declared actors on this site (lib/tracker/actors.ts): what visitors SAID +// they are, kept apart from the measured tiles above it. Names appear only +// for the viewer's own actors or ones their owners made public; everyone else +// is in the per-kind totals and nowhere else (declaredSummary does the +// filtering, the same function the API and CLI read). +export function DeclaredCard({ summary, rangeLabel }: { summary: DeclaredSummary | null; rangeLabel: string }) { + // Null means the actor tables are unreadable (or not migrated): say nothing + // rather than print zeros that read as "nobody declared". + if (!summary) return null; + const { human, agent } = summary.totals; + const contradictions = human.contradictions + agent.contradictions; + + if (human.events + agent.events === 0) { + return ( +

+ No declared visitors in this window.{" "} + + Declare yourself or your agents → + +

+ ); + } + + return ( +
+
+
+

Declared visitors

+

+ {rangeLabel}. Self-reported, opt-in: an agent is believed and counted as a bot; a human + never overrides bot detection. +

+
+ + Manage actors → + +
+ +
+ + +
+
Contradictions
+
+ {contradictions.toLocaleString()} +
+
+ Hits declared human that detection called a bot +
+
+
+ + {summary.actors.length > 0 && ( +
+ + + + + + + + + + + + {summary.actors.map((a) => ( + + + + + + + + ))} + +
ActorKindPageviewsEventsContradicted
+ {a.name || a.email} + {a.name && {a.email}} + {!a.mine && (public)} + {a.kind}{a.pageviews.toLocaleString()}{a.events.toLocaleString()} + {a.contradictions.toLocaleString()} +
+
+ )} + {summary.actors.length === 0 && ( +

+ None of these actors are yours or public, so only the totals are shown. +

+ )} +
+ ); +} + +function Tile({ label, kind }: { label: string; kind: { actors: number; events: number; pageviews: number } }) { + return ( +
+
{label}
+
{kind.actors.toLocaleString()}
+
+ {kind.pageviews.toLocaleString()} pageviews, {kind.events.toLocaleString()} events +
+
+ ); +} diff --git a/app/(app)/dashboard/projects/[id]/stats/page.tsx b/app/(app)/dashboard/projects/[id]/stats/page.tsx index 8472824..a88f82a 100644 --- a/app/(app)/dashboard/projects/[id]/stats/page.tsx +++ b/app/(app)/dashboard/projects/[id]/stats/page.tsx @@ -23,6 +23,10 @@ import { AutoInstall } from "./auto-install"; import { LiveVisitors } from "./live-visitors"; import { StatsSubnav } from "./stats-subnav"; import { WhoToggle } from "./who-toggle"; +import { DeclaredCard } from "./declared-card"; +import { serviceClient } from "@/lib/supabase/service"; +import { declaredSummary } from "@/lib/tracker/actorStore"; +import { DECLARED_DEFINITION } from "@/lib/tracker/actors"; import { getOrMintInstallationToken } from "@/lib/github/installations"; import { listInstallationRepos } from "@/lib/github/app"; @@ -122,6 +126,14 @@ export default async function ProjectStatsPage({ // no connected installations, we just hide the button. const ghConfigured = !!(env.githubAppId && env.githubAppPrivateKey); const { data: { user } } = await supabase.auth.getUser(); + + // Declared actors (opt-in, self-reported). Read with the service client + // because naming an actor needs a join the viewer's RLS cannot see; + // declaredSummary itself filters names to the viewer's own or public ones. + // Best-effort: a failure hides the card instead of breaking the page. + const declared = user + ? await declaredSummary(serviceClient(), user.id, id, range, DECLARED_DEFINITION).catch(() => null) + : null; const installations: Array<{ installation_id: number; account_login: string }> = []; const ghRepos: Array<{ full_name: string; @@ -295,6 +307,8 @@ export default async function ProjectStatsPage({

{visitorsCaption}

)} + + {grandTotal === 0 && eventTotal === 0 ? (

diff --git a/app/(marketing)/docs/statistics/page.tsx b/app/(marketing)/docs/statistics/page.tsx index cae5bb2..3beba1a 100644 --- a/app/(marketing)/docs/statistics/page.tsx +++ b/app/(marketing)/docs/statistics/page.tsx @@ -147,6 +147,22 @@ https://example.com/?crp_actor=cpa_… # or from page code window.crawlproof?.("actor", "cpa_…"); // null forgets it`} +

+ For an agent's browser, generate an extension instead of changing + its code. It sends the token on{" "} + /api/track requests and nothing + else, so the sites the agent visits never see it (a blanket extra + header on every request would hand them the token): +

+
{`crawlproof actors extension mybot@example.com --out=./crawlproof-declare
+
+chromium --load-extension=./crawlproof-declare --disable-extensions-except=./crawlproof-declare
+# chrome-devtools-mcp: --chromeArg=--load-extension= --chromeArg=--disable-extensions-except=
+# Playwright: launchPersistentContext(profile, { args: [the same two flags] })`}
+

+ Use Chromium or Chrome for Testing: branded Google Chrome 137 and + later ignores --load-extension. +

It is self-reported, so the rule is one-way: a declared agent is believed and counted as a bot; a declared human is recorded but never diff --git a/cli/index.ts b/cli/index.ts index 4bd0b65..ac20813 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -1057,7 +1057,7 @@ async function main() { return await runActors(args.positional, args.flags as Record, (method, path, body) => apiCall(args, method, path, body), { write: (line: string) => process.stdout.write(`${line}\n`), error: (line: string) => console.error(line), - }); + }, { base: apiBase(args) }); case "dashboard": case "roi": case "tui": diff --git a/lib/tracker/actorsCli.ts b/lib/tracker/actorsCli.ts index 9ed9873..4cc8181 100644 --- a/lib/tracker/actorsCli.ts +++ b/lib/tracker/actorsCli.ts @@ -8,6 +8,10 @@ // Model and trust rule: lib/tracker/actors.ts. Opt-in and self-reported; an // agent is believed, a human never overrides bot detection. +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { declareExtensionFiles } from "./declareExtension"; + type Method = "GET" | "POST" | "PATCH" | "DELETE"; type ApiCall = (method: Method, path: string, body?: Record) => Promise<{ status: number; json: Record }>; type Out = { write: (line: string) => void; error: (line: string) => void }; @@ -27,7 +31,8 @@ export const ACTORS_USAGE = ` actors [list] [--json] actors add --kind=human|agent [--name=…] [--operator=] [--public] [--token-label=…] [--no-token] [--json] actors token [--label=…] - actors revoke [--token=] + actors revoke [--token-id=] + actors extension [--out=./crawlproof-declare] [--label=…] Declared actors: say who you are, and whether you are a person, on every site with the CrawlProof tracker. Opt-in and self-reported. A token (cpa_…) is the credential, never the email; send it as the @@ -36,6 +41,13 @@ export const ACTORS_USAGE = ` actors [list] [--json] is counted as a contradiction. Names are visible to you only unless --public. Your login address is verified on creation; any other gets a verification email. Needs an API token. + + \`actors extension\` mints a token and writes an unpacked Chrome + extension that sends it on /api/track requests ONLY, for an agent's + browser: chrome --load-extension=

--disable-extensions-except=. + (A blanket extra header on every request would hand the token to every + site the agent visits.) Chromium or Chrome for Testing; branded Chrome + 137+ ignores --load-extension. `; /** How to send a fresh actor token. Pure, for tests. */ @@ -57,6 +69,7 @@ export async function runActors( flags: Record, call: ApiCall, out: Out, + opts: { base?: string } = {}, ): Promise { const sub = positional[0] ?? "list"; const json = Boolean(flags.json); @@ -141,10 +154,12 @@ export async function runActors( if (r.status >= 400) return fail("revoke", r); const actor = find(actors, positional[1]); if (!actor) { - out.error("usage: crawlproof actors revoke [--token=] (no --token revokes the actor and every token)"); + out.error("usage: crawlproof actors revoke [--token-id=] (no --token-id revokes the actor and every token)"); return 2; } - const tokenId = typeof flags.token === "string" ? flags.token : undefined; + // Not --token: both CLIs read --token as the API key override, so a token id + // there was sent as the bearer and came back 401 "Malformed token". + const tokenId = typeof flags["token-id"] === "string" ? flags["token-id"] : undefined; const d = tokenId ? await call("DELETE", `/api/tracker/v1/actors/${actor.id}/tokens?token=${encodeURIComponent(tokenId)}`) : await call("DELETE", `/api/tracker/v1/actors/${actor.id}`); @@ -153,6 +168,39 @@ export async function runActors( return 0; } - out.error(`unknown: crawlproof actors ${sub} (expected: list | add | token | revoke)`); + if (sub === "extension") { + const { r, actors } = await list(); + if (r.status >= 400) return fail("extension", r); + const actor = find(actors, positional[1]); + if (!actor) { + out.error("usage: crawlproof actors extension [--out=./crawlproof-declare] [--label=…] (crawlproof actors list shows yours)"); + return 2; + } + const dir = resolve(typeof flags.out === "string" ? flags.out : "crawlproof-declare"); + const label = typeof flags.label === "string" ? flags.label : "browser extension"; + const m = await call("POST", `/api/tracker/v1/actors/${actor.id}/tokens`, { label }); + if (m.status >= 400) return fail("extension", m); + const files = declareExtensionFiles({ + token: String(m.json.token), + base: opts.base ?? "https://crawlproof.com", + who: `${actor.name || actor.email} (${actor.kind})`, + }); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body, { mode: 0o600 }); + if (json) { + out.write(JSON.stringify({ dir, token_id: m.json.id, prefix: m.json.prefix }, null, 2)); + return 0; + } + out.write(`wrote ${dir} for ${actor.email} (${actor.kind}), token ${String(m.json.prefix)}… id ${String(m.json.id)}`); + out.write(""); + out.write(` chrome --load-extension=${dir} --disable-extensions-except=${dir} …`); + out.write(` chrome-devtools-mcp --chromeArg=--load-extension=${dir} --chromeArg=--disable-extensions-except=${dir}`); + out.write(""); + out.write(`Revoke: crawlproof actors revoke ${actor.email} --token-id=${String(m.json.id)}`); + return 0; + } + + out.error(`unknown: crawlproof actors ${sub} (expected: list | add | token | revoke | extension)`); return 2; } diff --git a/lib/tracker/declareExtension.ts b/lib/tracker/declareExtension.ts new file mode 100644 index 0000000..09c882f --- /dev/null +++ b/lib/tracker/declareExtension.ts @@ -0,0 +1,76 @@ +// The "declare me" Chrome extension for an agent's (or a person's) browser. +// +// An unpacked Manifest V3 extension with one declarativeNetRequest rule: set +// `Crawlproof-Actor: ` on requests to /api/track and nothing +// else. Scoping the header to the beacon endpoint is the point. A blanket +// "extra header on every request" (Playwright extraHTTPHeaders, Puppeteer +// setExtraHTTPHeaders) hands the token to every site the agent visits, and any +// of them could replay it to pose as that agent. +// +// No dependencies, so both CLIs can bundle it. `crawlproof actors extension` +// writes these files; Chrome loads them with --load-extension=. + +export type ExtensionFiles = Record<"manifest.json" | "rules.json" | "README.txt", string>; + +/** "https://crawlproof.com/" -> "https://crawlproof.com". Throws on a non-http(s) base. */ +export function trackOrigin(base: string): string { + const u = new URL(base); + if (u.protocol !== "https:" && u.protocol !== "http:") throw new Error(`not an http(s) URL: ${base}`); + return u.origin; +} + +export function declareExtensionFiles(input: { token: string; base: string; who: string }): ExtensionFiles { + if (!/^cpa_[A-Za-z0-9_-]{32,124}$/.test(input.token)) throw new Error("not a cpa_ token"); + const origin = trackOrigin(input.base); + const manifest = { + manifest_version: 3, + name: "CrawlProof declared actor", + version: "1.0.0", + description: `Declares this browser's visits as ${input.who} to the CrawlProof tracker. Adds one header to ${origin}/api/track requests only.`, + // WithHostAccess: modifyHeaders needs host access to the request URL and + // to the page that sends it, which can be any tracked site. + permissions: ["declarativeNetRequestWithHostAccess"], + host_permissions: [""], + declarative_net_request: { + rule_resources: [{ id: "declare", enabled: true, path: "rules.json" }], + }, + }; + const rules = [ + { + id: 1, + priority: 1, + action: { + type: "modifyHeaders", + requestHeaders: [{ header: "Crawlproof-Actor", operation: "set", value: input.token }], + }, + condition: { + // Left-anchored on the full origin + path: a lookalike host or a page + // whose own URL merely contains this string does not match. + urlFilter: `|${origin}/api/track`, + resourceTypes: ["xmlhttprequest", "ping", "other"], + }, + }, + ]; + const readme = [ + `CrawlProof declared actor: ${input.who}`, + "", + `Every page this browser loads that runs the CrawlProof tracker is counted as`, + `${input.who}. The token goes only to ${origin}/api/track.`, + "", + "Load it:", + " chrome --load-extension=$PWD --disable-extensions-except=$PWD ...", + " Puppeteer: args: [`--load-extension=${dir}`, `--disable-extensions-except=${dir}`]", + " Playwright: chromium.launchPersistentContext(profile, { args: [same two flags] })", + " chrome-devtools-mcp: --chromeArg=--load-extension= --chromeArg=--disable-extensions-except=", + "", + "Branded Google Chrome 137+ ignores --load-extension; use Chromium or Chrome for Testing.", + "rules.json holds the token: keep this folder private (chmod 700).", + "Revoke: crawlproof actors revoke --token-id=", + "", + ].join("\n"); + return { + "manifest.json": `${JSON.stringify(manifest, null, 2)}\n`, + "rules.json": `${JSON.stringify(rules, null, 2)}\n`, + "README.txt": readme, + }; +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 2d65921..38fb04d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/crawlproof", - "version": "0.4.0", + "version": "0.5.0", "description": "What the fleet costs and what it returns: a live terminal dashboard over CrawlProof traffic, ad delivery and CoinPay banking.", "license": "MIT", "type": "module", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7a40c91..950ad63 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -17,7 +17,7 @@ import { FINANCE_DAYS, runDashboard } from "../../../cli/dashboard"; import { EMAIL_TRACKING_USAGE, runEmailTracking } from "../../../lib/emailTracking/cli"; import { ACTORS_USAGE, runActors } from "../../../lib/tracker/actorsCli"; -export const VERSION = "0.4.0"; +export const VERSION = "0.5.0"; type Args = { command: string; @@ -420,7 +420,7 @@ export async function main(argv: string[]): Promise { return await runActors(args.positional, args.flags, (method, path, body) => apiCall(args, method, path, body), { write: (line: string) => process.stdout.write(`${line}\n`), error: (line: string) => console.error(line), - }); + }, { base: apiBase(args) }); case "email-tracking": return await runEmailTracking(args.positional, args.flags, (method, path) => apiCall(args, method, path), { write: (line: string) => process.stdout.write(`${line}\n`), diff --git a/tests/tracker-declare-extension.test.ts b/tests/tracker-declare-extension.test.ts new file mode 100644 index 0000000..8ff87c3 --- /dev/null +++ b/tests/tracker-declare-extension.test.ts @@ -0,0 +1,105 @@ +import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { declareExtensionFiles, trackOrigin } from "@/lib/tracker/declareExtension"; +import { runActors } from "@/lib/tracker/actorsCli"; + +// The extension is how an agent's browser declares itself without a code +// change. What has to hold: the token goes to the beacon endpoint and nowhere +// else, and the folder holding it is private. + +const TOKEN = "cpa_" + "A".repeat(43); + +describe("declareExtensionFiles", () => { + const files = declareExtensionFiles({ token: TOKEN, base: "https://crawlproof.com/", who: "riotcoder (agent)" }); + const rules = JSON.parse(files["rules.json"]); + const manifest = JSON.parse(files["manifest.json"]); + + it("sets the header only on /api/track, left-anchored", () => { + expect(rules).toHaveLength(1); + expect(rules[0].condition.urlFilter).toBe("|https://crawlproof.com/api/track"); + expect(rules[0].action.requestHeaders).toEqual([{ header: "Crawlproof-Actor", operation: "set", value: TOKEN }]); + }); + + it("is a Manifest V3 declarativeNetRequest extension with no other powers", () => { + expect(manifest.manifest_version).toBe(3); + expect(manifest.permissions).toEqual(["declarativeNetRequestWithHostAccess"]); + expect(manifest.background).toBeUndefined(); + expect(manifest.content_scripts).toBeUndefined(); + }); + + it("follows a self-hosted base", () => { + const self = JSON.parse(declareExtensionFiles({ token: TOKEN, base: "http://localhost:3000", who: "x" })["rules.json"]); + expect(self[0].condition.urlFilter).toBe("|http://localhost:3000/api/track"); + }); + + it("refuses a non-token and a non-http base", () => { + expect(() => declareExtensionFiles({ token: "crp_" + "A".repeat(43), base: "https://crawlproof.com", who: "x" })).toThrow(); + expect(() => trackOrigin("file:///etc/passwd")).toThrow(); + }); +}); + +describe("crawlproof actors extension", () => { + let dir = ""; + afterEach(() => dir && rmSync(dir, { recursive: true, force: true })); + + it("mints a token for the actor and writes a private folder", async () => { + dir = mkdtempSync(join(tmpdir(), "cp-ext-")); + const out = join(dir, "declare"); + const calls: [string, string, unknown][] = []; + const call = async (method: string, path: string, body?: Record) => { + calls.push([method, path, body]); + if (method === "GET") { + return { status: 200, json: { actors: [{ id: "a2", email: "riotcoder@profullstack.com", name: "riotcoder", kind: "agent", email_verified: true, visibility: "private", tokens: [], last30: { events: 0, pageviews: 0, contradictions: 0, sites: 0 } }] } }; + } + return { status: 201, json: { id: "t9", token: TOKEN, prefix: "cpa_AAAA" } }; + }; + const lines: string[] = []; + const code = await runActors( + ["extension", "riotcoder@profullstack.com"], + { out }, + call, + { write: (l) => lines.push(l), error: (l) => lines.push(`ERR ${l}`) }, + { base: "https://crawlproof.com" }, + ); + expect(code).toBe(0); + expect(calls[1]).toEqual(["POST", "/api/tracker/v1/actors/a2/tokens", { label: "browser extension" }]); + expect(JSON.parse(readFileSync(join(out, "rules.json"), "utf8"))[0].action.requestHeaders[0].value).toBe(TOKEN); + expect(statSync(out).mode & 0o777).toBe(0o700); + expect(statSync(join(out, "rules.json")).mode & 0o777).toBe(0o600); + expect(lines.join("\n")).toContain("--load-extension="); + }); + + it("asks for an actor it can find instead of guessing", async () => { + const call = async () => ({ status: 200, json: { actors: [] } }); + const errors: string[] = []; + expect(await runActors(["extension", "nobody@example.com"], {}, call, { write: () => {}, error: (l) => errors.push(l) })).toBe(2); + expect(errors[0]).toContain("usage: crawlproof actors extension"); + }); +}); + +describe("actors revoke --token-id (regression)", () => { + it("revokes one token by id, and --token-id never becomes the API key", async () => { + const { apiToken, parseArgs } = await import("@/packages/cli/src/cli"); + const args = parseArgs(["actors", "revoke", "riotcoder@profullstack.com", "--token-id=t9"]); + // --token is the CLI-wide API key override; the token id must not land there. + expect(args.flags.token).toBeUndefined(); + const prev = process.env.CRAWLPROOF_TOKEN; + const FAKE_KEY = "crp_" + "x".repeat(40); // fixture, not a credential + process.env.CRAWLPROOF_TOKEN = FAKE_KEY; + expect(apiToken(args)).toBe(FAKE_KEY); + if (prev === undefined) delete process.env.CRAWLPROOF_TOKEN; + else process.env.CRAWLPROOF_TOKEN = prev; + + const calls: [string, string][] = []; + const call = async (method: string, path: string) => { + calls.push([method, path]); + return method === "GET" + ? { status: 200, json: { actors: [{ id: "a2", email: "riotcoder@profullstack.com", name: "", kind: "agent", email_verified: true, visibility: "private", tokens: [], last30: { events: 0, pageviews: 0, contradictions: 0, sites: 0 } }] } } + : { status: 200, json: { ok: true } }; + }; + expect(await runActors(args.positional, args.flags, call, { write: () => {}, error: () => {} })).toBe(0); + expect(calls[1]).toEqual(["DELETE", "/api/tracker/v1/actors/a2/tokens?token=t9"]); + }); +}); diff --git a/tests/tracker-declared-actors.test.ts b/tests/tracker-declared-actors.test.ts index a507880..9820c54 100644 --- a/tests/tracker-declared-actors.test.ts +++ b/tests/tracker-declared-actors.test.ts @@ -324,6 +324,6 @@ describe("runActors (shared by both CLIs)", () => { it("is wired into the published CLI, not only the in-repo one", () => { const src = readFileSync("packages/cli/src/cli.ts", "utf8"); expect(src).toContain('case "actors":'); - expect(src).toContain('export const VERSION = "0.4.0";'); + expect(src).toContain('export const VERSION = "0.5.0";'); }); });