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
107 changes: 107 additions & 0 deletions app/(app)/dashboard/projects/[id]/stats/declared-card.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<p className="text-xs text-[var(--color-muted)]">
No declared visitors in this window.{" "}
<Link href="/dashboard/settings/actors" className="underline hover:text-[var(--color-foreground)]">
Declare yourself or your agents →
</Link>
</p>
);
}

return (
<section className="card p-4 space-y-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<h2 className="text-lg font-semibold">Declared visitors</h2>
<p className="text-sm text-[var(--color-muted)]">
{rangeLabel}. Self-reported, opt-in: an agent is believed and counted as a bot; a human
never overrides bot detection.
</p>
</div>
<Link href="/dashboard/settings/actors" className="text-sm underline hover:text-[var(--color-foreground)]">
Manage actors →
</Link>
</div>

<div className="grid gap-3 sm:grid-cols-3">
<Tile label="Declared humans" kind={human} />
<Tile label="Declared agents" kind={agent} />
<div className="rounded-md border border-[var(--color-border)] p-3">
<div className="text-xs text-[var(--color-muted)]">Contradictions</div>
<div className={`mt-1 text-2xl font-extrabold ${contradictions ? "text-[var(--color-fail)]" : ""}`}>
{contradictions.toLocaleString()}
</div>
<div className="text-xs text-[var(--color-muted)]">
Hits declared human that detection called a bot
</div>
</div>
</div>

{summary.actors.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-[var(--color-muted)]">
<th className="py-1 pr-3 font-medium">Actor</th>
<th className="py-1 pr-3 font-medium">Kind</th>
<th className="py-1 pr-3 font-medium text-right">Pageviews</th>
<th className="py-1 pr-3 font-medium text-right">Events</th>
<th className="py-1 font-medium text-right">Contradicted</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{summary.actors.map((a) => (
<tr key={`${a.email}-${a.kind}`}>
<td className="py-1.5 pr-3">
{a.name || a.email}
{a.name && <span className="ml-1 text-xs text-[var(--color-muted)]">{a.email}</span>}
{!a.mine && <span className="ml-1 text-xs text-[var(--color-muted)]">(public)</span>}
</td>
<td className="py-1.5 pr-3">{a.kind}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{a.pageviews.toLocaleString()}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{a.events.toLocaleString()}</td>
<td className={`py-1.5 text-right tabular-nums ${a.contradictions ? "text-[var(--color-fail)]" : ""}`}>
{a.contradictions.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{summary.actors.length === 0 && (
<p className="text-xs text-[var(--color-muted)]">
None of these actors are yours or public, so only the totals are shown.
</p>
)}
</section>
);
}

function Tile({ label, kind }: { label: string; kind: { actors: number; events: number; pageviews: number } }) {
return (
<div className="rounded-md border border-[var(--color-border)] p-3">
<div className="text-xs text-[var(--color-muted)]">{label}</div>
<div className="mt-1 text-2xl font-extrabold">{kind.actors.toLocaleString()}</div>
<div className="text-xs text-[var(--color-muted)]">
{kind.pageviews.toLocaleString()} pageviews, {kind.events.toLocaleString()} events
</div>
</div>
);
}
14 changes: 14 additions & 0 deletions app/(app)/dashboard/projects/[id]/stats/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -295,6 +307,8 @@ export default async function ProjectStatsPage({
<p className="-mt-1 text-xs text-[var(--color-muted)]">{visitorsCaption}</p>
)}

<DeclaredCard summary={declared} rangeLabel={range.description} />

{grandTotal === 0 && eventTotal === 0 ? (
<section className="card p-4">
<p className="text-sm text-[var(--color-muted)]">
Expand Down
16 changes: 16 additions & 0 deletions app/(marketing)/docs/statistics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,22 @@ https://example.com/?crp_actor=cpa_…

# or from page code
window.crawlproof?.("actor", "cpa_…"); // null forgets it`}</pre>
<p className="text-sm leading-relaxed">
For an agent&apos;s browser, generate an extension instead of changing
its code. It sends the token on{" "}
<code className="font-mono">/api/track</code> 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):
</p>
<pre className="overflow-x-auto rounded border border-[var(--color-border)] bg-[#0b0d10] p-3 font-mono text-xs leading-relaxed">{`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=<dir> --chromeArg=--disable-extensions-except=<dir>
# Playwright: launchPersistentContext(profile, { args: [the same two flags] })`}</pre>
<p className="text-sm leading-relaxed">
Use Chromium or Chrome for Testing: branded Google Chrome 137 and
later ignores <code className="font-mono">--load-extension</code>.
</p>
<p className="text-sm leading-relaxed">
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
Expand Down
2 changes: 1 addition & 1 deletion cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1057,7 +1057,7 @@ async function main() {
return await runActors(args.positional, args.flags as Record<string, string | boolean>, (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":
Expand Down
56 changes: 52 additions & 4 deletions lib/tracker/actorsCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => Promise<{ status: number; json: Record<string, unknown> }>;
type Out = { write: (line: string) => void; error: (line: string) => void };
Expand All @@ -27,7 +31,8 @@ export const ACTORS_USAGE = ` actors [list] [--json]
actors add <email> --kind=human|agent [--name=…] [--operator=<human email>]
[--public] [--token-label=…] [--no-token] [--json]
actors token <email|id> [--label=…]
actors revoke <email|id> [--token=<token id>]
actors revoke <email|id> [--token-id=<id>]
actors extension <email|id> [--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
Expand All @@ -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 <site>/api/track requests ONLY, for an agent's
browser: chrome --load-extension=<dir> --disable-extensions-except=<dir>.
(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. */
Expand All @@ -57,6 +69,7 @@ export async function runActors(
flags: Record<string, string | boolean>,
call: ApiCall,
out: Out,
opts: { base?: string } = {},
): Promise<number> {
const sub = positional[0] ?? "list";
const json = Boolean(flags.json);
Expand Down Expand Up @@ -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 <email|id> [--token=<token id>] (no --token revokes the actor and every token)");
out.error("usage: crawlproof actors revoke <email|id> [--token-id=<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}`);
Expand All @@ -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 <email|id> [--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;
}
76 changes: 76 additions & 0 deletions lib/tracker/declareExtension.ts
Original file line number Diff line number Diff line change
@@ -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: <cpa_ token>` on requests to <base>/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=<dir>.

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: ["<all_urls>"],
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=<dir> --chromeArg=--disable-extensions-except=<dir>",
"",
"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 <email> --token-id=<token id>",
"",
].join("\n");
return {
"manifest.json": `${JSON.stringify(manifest, null, 2)}\n`,
"rules.json": `${JSON.stringify(rules, null, 2)}\n`,
"README.txt": readme,
};
}
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -420,7 +420,7 @@ export async function main(argv: string[]): Promise<number> {
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`),
Expand Down
Loading