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 (
+
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";');
});
});