+ Your API token works on every /api/affiliate/v1/* route and in crawlproof affiliate. The affiliate token below reads only the ledger, which is what you hand to a third party.
+
+ );
+}
diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx
index a4affd09..dd0ee7a7 100644
--- a/app/(app)/layout.tsx
+++ b/app/(app)/layout.tsx
@@ -41,6 +41,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
New
Ads
Promote
+ Affiliate
GitHub
Blog ↗
{profile?.is_admin && Admin}
diff --git a/app/(marketing)/affiliate/page.tsx b/app/(marketing)/affiliate/page.tsx
new file mode 100644
index 00000000..16866e50
--- /dev/null
+++ b/app/(marketing)/affiliate/page.tsx
@@ -0,0 +1,99 @@
+import Link from "next/link";
+import { redirect } from "next/navigation";
+import { createClient } from "@/lib/supabase/server";
+import { HOLD_DAYS, PAYOUT_MIN_CENTS, PAYS, WINDOW_DAYS, termsLine } from "@/lib/affiliate/program";
+
+export const metadata = {
+ title: "Affiliate program — no network, no application, paid in USDC",
+ description:
+ "Send people to CrawlProof and earn a share of what they buy. The terms are a public file, joining is a profile, the link is one parameter, and the money goes from us to your wallet with nobody in between.",
+ alternates: { canonical: "/affiliate" },
+ openGraph: {
+ title: "CrawlProof affiliate program",
+ description: "Public terms, no application, one link parameter, paid in USDC on Polygon. An OpenAffiliate program.",
+ url: "/affiliate",
+ },
+};
+
+/**
+ * The public page for the program. Every figure comes from lib/affiliate/program
+ * so the pitch cannot drift from what the descriptor and the ledger pay.
+ */
+export default async function AffiliateMarketingPage() {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (user) redirect("/dashboard/affiliate");
+
+ const sale = PAYS.find((p) => p.event === "sale");
+ const rate = sale?.kind === "percent" ? `${sale.value}%` : sale ? `$${sale.value}` : "a share";
+
+ return (
+
+
+
CrawlProof partners
+
Earn {rate} of what you send us. No network in the way.
+
{termsLine()}
+
+
+ Get your link
+
+
+ Programs you can join
+
+
+
+
+
+
+
The terms are a file
+
+ Everything on this page is also at /.well-known/openaffiliate.json, in a shape any script or agent can read. What the file says is what the ledger pays, and the terms only ever change forward.
+
+
+
+
No application
+
+ Sign in and your link exists. Outside CrawlProof, POST an OpenProfile.md URL to the join endpoint and get a link and a ledger token back at once.
+
+
+
+
Nobody in the money
+
+ A conversion waits out the {HOLD_DAYS}-day refund window, then it is approved, then it is sent to your wallet in USDC on Polygon from ${(PAYOUT_MIN_CENTS / 100).toFixed(0)}. No network fee, because there is no network.
+
+
+
+
+
+
How a click becomes money
+
+
+ Add ?oa=yourcode to any CrawlProof page. The visitor lands on that page; the parameter is stripped and a {WINDOW_DAYS}-day window starts.
+
+
They sign in and buy credits inside the window. The purchase is recorded to you, pending, with the day it will be approved.
+
After {HOLD_DAYS} days with no refund it is approved. A reversal always says why.
+
Weekly, the approved balance goes to your wallet. Or press pay out now.
+
+
+ Only a real page visit sets the window. A parameter on an image, a frame or a script sets nothing, and your own purchases do not pay you.
+
+
+
+
+
Join other programs from the same place
+
+ Any merchant that serves an OpenAffiliate file can be joined from your dashboard with one profile, and every ledger shows on one page. That is the point of the spec: one shape, so an affiliate does not need ten dashboards, and a merchant does not need a network.
+
+
+
+ );
+}
diff --git a/app/(marketing)/affiliate/programs/page.tsx b/app/(marketing)/affiliate/programs/page.tsx
new file mode 100644
index 00000000..5d969a44
--- /dev/null
+++ b/app/(marketing)/affiliate/programs/page.tsx
@@ -0,0 +1,98 @@
+import Link from "next/link";
+import { listDirectory } from "@/lib/affiliate/directory";
+import { linkFor } from "@/lib/affiliate/spec";
+
+export const metadata = {
+ title: "Affiliate programs you can join — read from each merchant's own file",
+ description: "A directory of OpenAffiliate programs: what each merchant pays, for how long, with what hold, read from /.well-known/openaffiliate.json on the merchant's own origin.",
+ alternates: { canonical: "/affiliate/programs" },
+};
+export const revalidate = 300;
+
+function pays(p: Array<{ event: string; kind: string; value: number; months?: number }>): string {
+ return p.map((x) => `${x.kind === "percent" ? `${x.value}%` : `$${x.value}`} per ${x.event}${x.months ? ` × ${x.months} months` : ""}`).join(", ");
+}
+
+export default async function ProgramsDirectoryPage() {
+ const programs = await listDirectory();
+ return (
+
+
OpenAffiliate directory
+
Programs you can join
+
+ Every row was read from the merchant's own /.well-known/openaffiliate.json. Verified means it came from the merchant's origin; the terms are shown with the time they were read, and the merchant's own terms link beside them. Nothing here takes a share of anything.
+
+ )}
+
+ );
+}
diff --git a/app/(marketing)/affiliate/terms/page.tsx b/app/(marketing)/affiliate/terms/page.tsx
new file mode 100644
index 00000000..59ce8b66
--- /dev/null
+++ b/app/(marketing)/affiliate/terms/page.tsx
@@ -0,0 +1,49 @@
+import { HOLD_DAYS, PAYOUT_MIN_CENTS, PAYS, WINDOW_DAYS } from "@/lib/affiliate/program";
+
+export const metadata = {
+ title: "Affiliate program terms",
+ description: "The terms of the CrawlProof partner program, the same ones served at /.well-known/openaffiliate.json.",
+ alternates: { canonical: "/affiliate/terms" },
+};
+
+export default function AffiliateTermsPage() {
+ const sale = PAYS.find((p) => p.event === "sale");
+ const rate = sale?.kind === "percent" ? `${sale.value} percent of the amount paid` : sale ? `$${sale.value}` : "nothing";
+ return (
+
+
Partner program terms
+
+ These are the terms in prose. The machine-readable copy at /.well-known/openaffiliate.json is the same terms, and where the two differ the file is what the ledger pays.
+
+
+
+ Who may join. Anyone with an account here, and any person, agent or organisation with an OpenProfile.md. Joining is open: a membership is active at once. We may end a membership for fraud, for sending traffic that breaks the law or our terms of service, or for misrepresenting CrawlProof, and we say why.
+
+
+ What pays. A credits purchase completed within {WINDOW_DAYS} days of a click on your link pays {rate}, net of tax and any refund. Sign-ups, views and clicks pay nothing on their own. Your own purchases pay nothing.
+
+
+ Attribution. Only a real page visit carrying ?oa=yourcode starts the window. A later click by a different affiliate replaces it. A parameter on an image, frame, script or prefetch sets nothing; placing one is grounds to end the membership.
+
+
+ Hold and reversal. A conversion is pending for {HOLD_DAYS} days, then approved. A refund or chargeback in that time reverses it, and every reversal carries its reason in your ledger.
+
+
+ Payment. Approved commission is sent in USDC on Polygon to the address on your membership, weekly, once it reaches ${(PAYOUT_MIN_CENTS / 100).toFixed(0)}, or sooner on request. We send the whole approved balance and take nothing from it. The transaction hash is in your ledger.
+
+
+ Disclosure. Say it is a paid partner link where the law where you are asks you to. We ask for the words "paid partner link" or their equivalent.
+
+
+ Changes. Terms change forward only. A conversion keeps the terms that stood when its click happened, and a change is announced in the file's updated field and by webhook to memberships that gave one.
+
+
+ Your data. Your ledger shows an opaque order handle and amounts, never the customer. We keep click records for the window plus the hold and delete them after.
+
+
+
+ This program follows OpenAffiliate 0.1. Operator: Profullstack, Inc.
+
+
+ );
+}
diff --git a/app/.well-known/openaffiliate-jwks.json/route.ts b/app/.well-known/openaffiliate-jwks.json/route.ts
new file mode 100644
index 00000000..4da7499c
--- /dev/null
+++ b/app/.well-known/openaffiliate-jwks.json/route.ts
@@ -0,0 +1,13 @@
+// The public key our affiliate webhooks are signed with (spec, "Webhooks").
+// An empty set means webhooks are unsigned and the ledger is the truth.
+import { NextResponse } from "next/server";
+import { publicJwk } from "@/lib/affiliate/webhooks";
+
+export const dynamic = "force-dynamic";
+
+export function GET() {
+ const jwk = publicJwk();
+ return NextResponse.json({ keys: jwk ? [jwk] : [] }, {
+ headers: { "cache-control": "public, max-age=3600", "access-control-allow-origin": "*" },
+ });
+}
diff --git a/app/.well-known/openaffiliate.json/route.ts b/app/.well-known/openaffiliate.json/route.ts
new file mode 100644
index 00000000..3ed1ae0e
--- /dev/null
+++ b/app/.well-known/openaffiliate.json/route.ts
@@ -0,0 +1,18 @@
+// The OpenAffiliate descriptor: the program CrawlProof runs, in its own words.
+// Spec: https://logicsrc.com/docs/openaffiliate
+import { NextResponse } from "next/server";
+import { env } from "@/lib/env";
+import { ourDescriptorJson } from "@/lib/affiliate/program";
+
+export const dynamic = "force-static";
+export const revalidate = 3600;
+
+// `updated` is the date the terms last changed, by hand: it tells a directory
+// whether to re-read the rest, so a build stamp would defeat it.
+const TERMS_UPDATED = "2026-09-13T00:00:00Z";
+
+export function GET() {
+ return NextResponse.json(ourDescriptorJson(env.siteUrl, TERMS_UPDATED), {
+ headers: { "cache-control": "public, max-age=3600", "access-control-allow-origin": "*" },
+ });
+}
diff --git a/app/actions/affiliate.ts b/app/actions/affiliate.ts
new file mode 100644
index 00000000..fb9be8fc
--- /dev/null
+++ b/app/actions/affiliate.ts
@@ -0,0 +1,100 @@
+"use server";
+
+// Server actions behind /dashboard/affiliate. Every one resolves the user
+// from the session first; nothing here trusts an id from the client.
+
+import { revalidatePath } from "next/cache";
+import { createClient } from "@/lib/supabase/server";
+import { ensureMembershipForUser, rotateToken, setPayAddress, setWebhook } from "@/lib/affiliate/memberships";
+import { requestPayout } from "@/lib/affiliate/payouts";
+import { addOrRefreshProgram, joinExternal, syncJoin } from "@/lib/affiliate/directory";
+import { isAffiliateCode, isPayAddress } from "@/lib/affiliate/spec";
+
+type Result = Record> = ({ ok: true } & T) | { ok: false; error: string };
+
+async function me(): Promise<{ id: string; email: string | null } | null> {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ return user ? { id: user.id, email: user.email ?? null } : null;
+}
+
+const PAGE = "/dashboard/affiliate";
+
+export async function savePayAddress(input: { pay: string }): Promise {
+ const user = await me();
+ if (!user) return { ok: false, error: "Sign in first." };
+ const pay = input.pay.trim();
+ if (pay && !isPayAddress(pay)) return { ok: false, error: "That is not a wallet address. It starts with 0x and is 42 characters." };
+ const m = await ensureMembershipForUser(user);
+ if (!m) return { ok: false, error: "Could not open your membership." };
+ const out = await setPayAddress(m.id, pay || null);
+ if (!out.ok) return out;
+ revalidatePath(PAGE);
+ return { ok: true };
+}
+
+export async function saveWebhook(input: { webhook: string }): Promise {
+ const user = await me();
+ if (!user) return { ok: false, error: "Sign in first." };
+ const w = input.webhook.trim();
+ if (w && !/^https:\/\//.test(w)) return { ok: false, error: "A webhook is an https URL." };
+ const m = await ensureMembershipForUser(user);
+ if (!m) return { ok: false, error: "Could not open your membership." };
+ await setWebhook(m.id, w || null);
+ revalidatePath(PAGE);
+ return { ok: true };
+}
+
+export async function rotateAffiliateToken(): Promise> {
+ const user = await me();
+ if (!user) return { ok: false, error: "Sign in first." };
+ const m = await ensureMembershipForUser(user);
+ if (!m) return { ok: false, error: "Could not open your membership." };
+ const token = await rotateToken(m.id);
+ revalidatePath(PAGE);
+ return { ok: true, token };
+}
+
+export async function requestAffiliatePayout(): Promise> {
+ const user = await me();
+ if (!user) return { ok: false, error: "Sign in first." };
+ const m = await ensureMembershipForUser(user);
+ if (!m) return { ok: false, error: "Could not open your membership." };
+ const out = await requestPayout(m);
+ if (!out.ok) return { ok: false, error: out.error };
+ revalidatePath(PAGE);
+ return { ok: true, amount: out.amountCents / 100, tx: out.txHash };
+}
+
+export async function addProgram(input: { url: string }): Promise> {
+ const user = await me();
+ if (!user) return { ok: false, error: "Sign in first." };
+ const url = input.url.trim();
+ if (!url) return { ok: false, error: "Paste the merchant's URL." };
+ const out = await addOrRefreshProgram(url, user.id);
+ if (!out.ok) return { ok: false, error: out.error };
+ revalidatePath(PAGE);
+ revalidatePath("/affiliate/programs");
+ return { ok: true, origin: out.row.origin, programs: out.row.descriptor?.programs.length ?? 0, warnings: out.warnings };
+}
+
+export async function joinProgramAction(input: { origin: string; program?: string; code?: string }): Promise> {
+ const user = await me();
+ if (!user) return { ok: false, error: "Sign in first." };
+ if (input.code && !isAffiliateCode(input.code)) return { ok: false, error: "A code is 3 to 32 lower-case letters, digits or dashes." };
+ const out = await joinExternal(user, { origin: input.origin, programId: input.program, code: input.code });
+ if (!out.ok) return { ok: false, error: out.error };
+ revalidatePath(PAGE);
+ return { ok: true, status: out.join.status, link: out.join.link };
+}
+
+export async function syncJoinAction(input: { id: string }): Promise {
+ const user = await me();
+ if (!user) return { ok: false, error: "Sign in first." };
+ const out = await syncJoin(input.id, user.id);
+ if (!out.ok) return { ok: false, error: out.error };
+ revalidatePath(PAGE);
+ return { ok: true };
+}
diff --git a/app/affiliate/creatives.json/route.ts b/app/affiliate/creatives.json/route.ts
new file mode 100644
index 00000000..c2646c9e
--- /dev/null
+++ b/app/affiliate/creatives.json/route.ts
@@ -0,0 +1,17 @@
+// GET /affiliate/creatives.json — what an affiliate may use as given (spec,
+// rule 10). The house ad artwork already in public/ads/house.
+import { NextResponse } from "next/server";
+import { env } from "@/lib/env";
+
+export const dynamic = "force-static";
+
+export function GET() {
+ const site = env.siteUrl.replace(/\/$/, "");
+ return NextResponse.json(
+ [
+ { url: `${site}/logo.svg`, kind: "logo", alt: "CrawlProof" },
+ { url: `${site}/banner.png`, kind: "banner", alt: "CrawlProof: see who is reading your site, and get paid for it" },
+ ],
+ { headers: { "cache-control": "public, max-age=3600", "access-control-allow-origin": "*" } },
+ );
+}
diff --git a/app/affiliate/u/[code]/openprofile.md/route.ts b/app/affiliate/u/[code]/openprofile.md/route.ts
new file mode 100644
index 00000000..fb29da62
--- /dev/null
+++ b/app/affiliate/u/[code]/openprofile.md/route.ts
@@ -0,0 +1,41 @@
+// GET /affiliate/u/:code/openprofile.md — an OpenProfile.md for one of our
+// affiliates, so they can join other merchants' programs with a profile URL
+// that answers. Public by nature (it is what a merchant reads); it carries
+// only what the affiliate put on their membership.
+import { NextResponse } from "next/server";
+import { env } from "@/lib/env";
+import { linkForMembership, membershipByCode } from "@/lib/affiliate/memberships";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function GET(_req: Request, ctx: { params: Promise<{ code: string }> }) {
+ const { code } = await ctx.params;
+ const m = await membershipByCode(code.toLowerCase());
+ if (!m || m.status === "refused" || m.status === "ended") return new NextResponse("Not found", { status: 404 });
+ const site = env.siteUrl.replace(/\/$/, "");
+ const name = m.displayName ?? m.code;
+ const lines = [
+ `# ${name}`,
+ "",
+ `Kind: ${m.kind}`,
+ `Handle: ${m.code}`,
+ `Web: ${site}/affiliate/u/${m.code}`,
+ ...(m.payAddress ? [`Pay: ${m.payAddress.includes(":") ? m.payAddress : `eip155:137:${m.payAddress}`}`] : []),
+ "",
+ `CrawlProof affiliate since ${m.createdAt.slice(0, 10)}.`,
+ "",
+ "## Accounts",
+ "",
+ `- ${linkForMembership(m)}`,
+ ...(m.profileUrl ? [`- ${m.profileUrl}`] : []),
+ "",
+ "## Operator",
+ "",
+ `- ${site}/.well-known/openprofile.md`,
+ "",
+ ];
+ return new NextResponse(lines.join("\n"), {
+ headers: { "content-type": "text/markdown; charset=utf-8", "cache-control": "public, max-age=300" },
+ });
+}
diff --git a/app/api/affiliate/v1/click/route.ts b/app/api/affiliate/v1/click/route.ts
new file mode 100644
index 00000000..36ba7e91
--- /dev/null
+++ b/app/api/affiliate/v1/click/route.ts
@@ -0,0 +1,53 @@
+// GET /api/affiliate/v1/click?oa=&to= — the landing for a
+// navigation that carried ?oa=. The middleware sends every such navigation
+// here; this records the click, sets the attribution cookie, and redirects
+// to the same path with the parameter stripped (spec, "Links and
+// attribution" rule 2). An unknown code still lands the visitor on the page.
+import { NextResponse, type NextRequest } from "next/server";
+import { COOKIE_NAME, encodeCookie } from "@/lib/affiliate/cookie";
+import { recordClick } from "@/lib/affiliate/attribution";
+import { membershipByCode } from "@/lib/affiliate/memberships";
+import { WINDOW_DAYS } from "@/lib/affiliate/program";
+import { isAffiliateCode } from "@/lib/affiliate/spec";
+import { env } from "@/lib/env";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+function safePath(to: string | null): string {
+ if (!to || !to.startsWith("/") || to.startsWith("//")) return "/";
+ return to;
+}
+
+export async function GET(req: NextRequest) {
+ const code = (req.nextUrl.searchParams.get("oa") ?? "").toLowerCase();
+ const to = safePath(req.nextUrl.searchParams.get("to"));
+ const dest = new URL(to, env.siteUrl);
+ const res = NextResponse.redirect(dest, 302);
+ res.headers.set("cache-control", "no-store");
+
+ if (!isAffiliateCode(code)) return res;
+ try {
+ const membership = await membershipByCode(code);
+ if (!membership || membership.status !== "active") return res;
+ const clickedAt = new Date();
+ res.cookies.set(COOKIE_NAME, encodeCookie({ code: membership.code, clickedAt }), {
+ path: "/",
+ maxAge: WINDOW_DAYS * 86_400,
+ sameSite: "lax",
+ httpOnly: true,
+ secure: env.siteUrl.startsWith("https://"),
+ });
+ const ip = req.headers.get("x-real-ip") ?? req.headers.get("x-forwarded-for")?.split(",").pop()?.trim() ?? null;
+ await recordClick({
+ membership,
+ landing: to,
+ referrer: req.headers.get("referer"),
+ ip,
+ userAgent: req.headers.get("user-agent"),
+ });
+ } catch (err) {
+ console.error("[affiliate] click", err);
+ }
+ return res;
+}
diff --git a/app/api/affiliate/v1/events/[join]/route.ts b/app/api/affiliate/v1/events/[join]/route.ts
new file mode 100644
index 00000000..fac82f53
--- /dev/null
+++ b/app/api/affiliate/v1/events/[join]/route.ts
@@ -0,0 +1,22 @@
+// POST /api/affiliate/v1/events/:join — a merchant we joined posting an
+// event about our membership there. The join id in the path is the only
+// credential a merchant without jwks can offer; it is unguessable, and a
+// forged event can do nothing but trigger a re-read of the real ledger.
+import { NextResponse, type NextRequest } from "next/server";
+import { recordInboundEvent } from "@/lib/affiliate/directory";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function POST(req: NextRequest, ctx: { params: Promise<{ join: string }> }) {
+ const { join } = await ctx.params;
+ if (!/^[0-9a-f-]{36}$/i.test(join)) return NextResponse.json({ error: "not found" }, { status: 404 });
+ let payload: unknown;
+ try {
+ payload = await req.json();
+ } catch {
+ return NextResponse.json({ error: "bad json" }, { status: 400 });
+ }
+ const ok = await recordInboundEvent(join, payload);
+ return NextResponse.json({ ok }, { status: ok ? 200 : 404 });
+}
diff --git a/app/api/affiliate/v1/join/route.ts b/app/api/affiliate/v1/join/route.ts
new file mode 100644
index 00000000..e268a1cf
--- /dev/null
+++ b/app/api/affiliate/v1/join/route.ts
@@ -0,0 +1,74 @@
+// POST /api/affiliate/v1/join — join the program CrawlProof runs.
+//
+// Public: an outside affiliate sends { profile, pay?, webhook?, code? } and
+// gets a membership, a code, a link and a token shown once. A signed-in user
+// or API token joins without a profile; the membership is theirs.
+import { NextResponse, type NextRequest } from "next/server";
+import { parseJoinRequest } from "@/lib/affiliate/spec";
+import { joinOurProgram, linkForMembership } from "@/lib/affiliate/memberships";
+import { PROGRAM_ID } from "@/lib/affiliate/program";
+import { env } from "@/lib/env";
+import { authenticateBearer } from "@/lib/sp/apiAuth";
+import { createClient } from "@/lib/supabase/server";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+const CORS = { "access-control-allow-origin": "*", "access-control-allow-headers": "content-type, authorization", "access-control-allow-methods": "POST, OPTIONS" };
+
+export function OPTIONS() {
+ return new NextResponse(null, { status: 204, headers: CORS });
+}
+
+export async function POST(req: NextRequest) {
+ let body: unknown = {};
+ try {
+ body = await req.json();
+ } catch {
+ body = {};
+ }
+ if (typeof body === "object" && body && "program" in body && (body as { program?: unknown }).program && (body as { program: string }).program !== PROGRAM_ID) {
+ return NextResponse.json({ error: `Unknown program. This merchant runs "${PROGRAM_ID}".` }, { status: 404, headers: CORS });
+ }
+
+ // Our own user, if any: the membership is linked to the account.
+ let owner: { id: string; email: string | null } | null = null;
+ const header = req.headers.get("authorization") ?? "";
+ if (/^bearer\s+crp_/i.test(header)) {
+ const auth = await authenticateBearer(req);
+ if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status, headers: CORS });
+ owner = { id: auth.userId, email: null };
+ } else if (!header) {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (user) owner = { id: user.id, email: user.email ?? null };
+ }
+
+ const parsed = parseJoinRequest(body);
+ if (!parsed.ok && !owner) return NextResponse.json({ error: parsed.error }, { status: 400, headers: CORS });
+ const request = parsed.ok ? parsed.request : {};
+
+ const joined = await joinOurProgram(request, { ownerId: owner?.id ?? null, email: owner?.email ?? null });
+ if (!joined.ok) return NextResponse.json({ error: joined.error }, { status: joined.status, headers: CORS });
+
+ const m = joined.membership;
+ return NextResponse.json(
+ {
+ membership: m.id,
+ program: m.program,
+ status: m.status,
+ code: m.code,
+ link: linkForMembership(m),
+ ...(joined.token ? { token: joined.token } : {}),
+ ledger: `${env.siteUrl.replace(/\/$/, "")}/api/affiliate/v1/ledger`,
+ pays: m.terms,
+ existing: joined.existing,
+ ...(joined.existing && !joined.token
+ ? { note: "This profile already has a membership. The token was shown at the first join; rotate it with POST /api/affiliate/v1/token from the account it belongs to." }
+ : {}),
+ },
+ { status: joined.existing ? 200 : 201, headers: CORS },
+ );
+}
diff --git a/app/api/affiliate/v1/joined/[id]/sync/route.ts b/app/api/affiliate/v1/joined/[id]/sync/route.ts
new file mode 100644
index 00000000..b3794dc1
--- /dev/null
+++ b/app/api/affiliate/v1/joined/[id]/sync/route.ts
@@ -0,0 +1,18 @@
+// POST /api/affiliate/v1/joined/:id/sync — read that merchant's ledger now.
+import { NextResponse, type NextRequest } from "next/server";
+import { resolveUser } from "@/lib/affiliate/auth";
+import { syncJoin } from "@/lib/affiliate/directory";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+export const maxDuration = 60;
+
+export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
+ const who = await resolveUser(req);
+ if (!who.ok) return NextResponse.json({ error: who.error }, { status: who.status });
+ const { id } = await ctx.params;
+ const out = await syncJoin(id, who.user.id);
+ if (!out.ok) return NextResponse.json({ error: out.error }, { status: 422 });
+ const j = out.join;
+ return NextResponse.json({ join: { id: j.id, origin: j.origin, program: j.programId, status: j.status, ledger: j.ledger, synced_at: j.syncedAt } });
+}
diff --git a/app/api/affiliate/v1/joined/route.ts b/app/api/affiliate/v1/joined/route.ts
new file mode 100644
index 00000000..1eec9c53
--- /dev/null
+++ b/app/api/affiliate/v1/joined/route.ts
@@ -0,0 +1,28 @@
+// GET /api/affiliate/v1/joined — the programs the caller has joined elsewhere, each with its last ledger read.
+import { NextResponse, type NextRequest } from "next/server";
+import { resolveUser } from "@/lib/affiliate/auth";
+import { listJoins } from "@/lib/affiliate/directory";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function GET(req: NextRequest) {
+ const who = await resolveUser(req);
+ if (!who.ok) return NextResponse.json({ error: who.error }, { status: who.status });
+ const joins = await listJoins(who.user.id);
+ return NextResponse.json({
+ joined: joins.map((j) => ({
+ id: j.id,
+ origin: j.origin,
+ program: j.programId,
+ status: j.status,
+ code: j.code,
+ link: j.link,
+ terms: j.terms,
+ ledger: j.ledger,
+ synced_at: j.syncedAt,
+ error: j.error,
+ joined_at: j.createdAt,
+ })),
+ }, { headers: { "cache-control": "no-store" } });
+}
diff --git a/app/api/affiliate/v1/ledger/route.ts b/app/api/affiliate/v1/ledger/route.ts
new file mode 100644
index 00000000..eaf0b92c
--- /dev/null
+++ b/app/api/affiliate/v1/ledger/route.ts
@@ -0,0 +1,23 @@
+// GET /api/affiliate/v1/ledger?since=… — the caller's own ledger (spec, "The ledger").
+import { NextResponse, type NextRequest } from "next/server";
+import { resolveCaller } from "@/lib/affiliate/auth";
+import { ledgerFor } from "@/lib/affiliate/memberships";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+const CORS = { "access-control-allow-origin": "*", "access-control-allow-headers": "authorization", "access-control-allow-methods": "GET, OPTIONS" };
+
+export function OPTIONS() {
+ return new NextResponse(null, { status: 204, headers: CORS });
+}
+
+export async function GET(req: NextRequest) {
+ const caller = await resolveCaller(req);
+ if (!caller.ok) return NextResponse.json({ error: caller.error }, { status: caller.status, headers: CORS });
+ const sinceRaw = req.nextUrl.searchParams.get("since");
+ const since = sinceRaw ? new Date(sinceRaw) : null;
+ if (since && Number.isNaN(since.getTime())) return NextResponse.json({ error: "since must be ISO 8601." }, { status: 400, headers: CORS });
+ const ledger = await ledgerFor(caller.membership, since);
+ return NextResponse.json(ledger, { headers: { ...CORS, "cache-control": "no-store" } });
+}
diff --git a/app/api/affiliate/v1/me/route.ts b/app/api/affiliate/v1/me/route.ts
new file mode 100644
index 00000000..67c3ec19
--- /dev/null
+++ b/app/api/affiliate/v1/me/route.ts
@@ -0,0 +1,61 @@
+// GET /api/affiliate/v1/me — the caller's membership, link and ledger.
+// POST /api/affiliate/v1/me { pay?, webhook? } — set where the money goes and where events go.
+import { NextResponse, type NextRequest } from "next/server";
+import { resolveCaller } from "@/lib/affiliate/auth";
+import { ledgerFor, membershipById, profileUrlForMembership, setPayAddress, setWebhook } from "@/lib/affiliate/memberships";
+import { isPayAddress } from "@/lib/affiliate/spec";
+import { HOLD_DAYS, PAYOUT_METHOD, PAYOUT_MIN_CENTS, WINDOW_DAYS, termsLine } from "@/lib/affiliate/program";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+async function answer(membershipId: string) {
+ const m = await membershipById(membershipId);
+ if (!m) return NextResponse.json({ error: "Membership vanished." }, { status: 404 });
+ const ledger = await ledgerFor(m);
+ return NextResponse.json({
+ membership: {
+ id: m.id,
+ program: m.program,
+ status: m.status,
+ code: m.code,
+ link: ledger.link,
+ profile: profileUrlForMembership(m),
+ pay_address: m.payAddress,
+ webhook: m.webhookUrl,
+ token_prefix: m.tokenPrefix,
+ created_at: m.createdAt,
+ },
+ terms: { pays: m.terms, window_days: WINDOW_DAYS, hold_days: HOLD_DAYS, payout_method: PAYOUT_METHOD, payout_min_cents: PAYOUT_MIN_CENTS, line: termsLine() },
+ ledger,
+ });
+}
+
+export async function GET(req: NextRequest) {
+ const caller = await resolveCaller(req);
+ if (!caller.ok) return NextResponse.json({ error: caller.error }, { status: caller.status });
+ return answer(caller.membership.id);
+}
+
+export async function POST(req: NextRequest) {
+ const caller = await resolveCaller(req);
+ if (!caller.ok) return NextResponse.json({ error: caller.error }, { status: caller.status });
+ let body: Record = {};
+ try {
+ body = (await req.json()) as Record;
+ } catch {
+ return NextResponse.json({ error: "Send JSON." }, { status: 400 });
+ }
+ if ("pay" in body) {
+ const pay = body.pay === null || body.pay === "" ? null : body.pay;
+ if (pay !== null && !isPayAddress(pay)) return NextResponse.json({ error: "pay must be an EVM address (0x…, 42 characters) or a CAIP-10 account." }, { status: 400 });
+ const set = await setPayAddress(caller.membership.id, pay as string | null);
+ if (!set.ok) return NextResponse.json({ error: set.error }, { status: 500 });
+ }
+ if ("webhook" in body) {
+ const w = body.webhook;
+ if (w !== null && w !== "" && (typeof w !== "string" || !/^https:\/\//.test(w))) return NextResponse.json({ error: "webhook must be an https URL." }, { status: 400 });
+ await setWebhook(caller.membership.id, w ? String(w) : null);
+ }
+ return answer(caller.membership.id);
+}
diff --git a/app/api/affiliate/v1/payout/route.ts b/app/api/affiliate/v1/payout/route.ts
new file mode 100644
index 00000000..fe0734c1
--- /dev/null
+++ b/app/api/affiliate/v1/payout/route.ts
@@ -0,0 +1,17 @@
+// POST /api/affiliate/v1/payout — send the approved balance now, if it is
+// over the minimum. The weekly schedule does the same without asking.
+import { NextResponse, type NextRequest } from "next/server";
+import { resolveCaller } from "@/lib/affiliate/auth";
+import { requestPayout } from "@/lib/affiliate/payouts";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+export const maxDuration = 60;
+
+export async function POST(req: NextRequest) {
+ const caller = await resolveCaller(req);
+ if (!caller.ok) return NextResponse.json({ error: caller.error }, { status: caller.status });
+ const out = await requestPayout(caller.membership);
+ if (!out.ok) return NextResponse.json({ ok: false, error: out.error }, { status: 400 });
+ return NextResponse.json({ ok: true, payout: { id: out.payoutId, amount: out.amountCents / 100, tx: out.txHash, status: out.status } });
+}
diff --git a/app/api/affiliate/v1/programs/join/route.ts b/app/api/affiliate/v1/programs/join/route.ts
new file mode 100644
index 00000000..61d4f3fc
--- /dev/null
+++ b/app/api/affiliate/v1/programs/join/route.ts
@@ -0,0 +1,34 @@
+// POST /api/affiliate/v1/programs/join { origin | url, program?, code? } —
+// join another merchant's program as the caller, with the caller's own
+// CrawlProof profile and pay address.
+import { NextResponse, type NextRequest } from "next/server";
+import { resolveUser } from "@/lib/affiliate/auth";
+import { joinExternal } from "@/lib/affiliate/directory";
+import { isAffiliateCode } from "@/lib/affiliate/spec";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+export const maxDuration = 60;
+
+export async function POST(req: NextRequest) {
+ const who = await resolveUser(req);
+ if (!who.ok) return NextResponse.json({ error: who.error }, { status: who.status });
+ let body: Record = {};
+ try {
+ body = (await req.json()) as Record;
+ } catch {
+ /* empty */
+ }
+ const origin = typeof body.origin === "string" ? body.origin : typeof body.url === "string" ? body.url : "";
+ if (!origin.trim()) return NextResponse.json({ error: "Send { origin } (or { url }) for the merchant." }, { status: 400 });
+ const programId = typeof body.program === "string" && body.program ? body.program : undefined;
+ const code = typeof body.code === "string" && body.code ? body.code : undefined;
+ if (code && !isAffiliateCode(code)) return NextResponse.json({ error: "code must be 3 to 32 lower-case letters, digits or dashes." }, { status: 400 });
+ const out = await joinExternal(who.user, { origin: origin.trim(), programId, code });
+ if (!out.ok) return NextResponse.json({ error: out.error }, { status: 422 });
+ const j = out.join;
+ return NextResponse.json({
+ join: { id: j.id, origin: j.origin, program: j.programId, status: j.status, code: j.code, link: j.link, ledger: j.ledgerUrl, terms: j.terms, synced_at: j.syncedAt },
+ existing: out.existing,
+ }, { status: out.existing ? 200 : 201 });
+}
diff --git a/app/api/affiliate/v1/programs/route.ts b/app/api/affiliate/v1/programs/route.ts
new file mode 100644
index 00000000..8bcecbdb
--- /dev/null
+++ b/app/api/affiliate/v1/programs/route.ts
@@ -0,0 +1,60 @@
+// GET /api/affiliate/v1/programs — the directory: every program read from a merchant's own file.
+// POST /api/affiliate/v1/programs { url } — read a merchant and add it (signed in or API token).
+import { NextResponse, type NextRequest } from "next/server";
+import { resolveUser } from "@/lib/affiliate/auth";
+import { addOrRefreshProgram, listDirectory } from "@/lib/affiliate/directory";
+import { linkFor } from "@/lib/affiliate/spec";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+export const maxDuration = 60;
+
+export async function GET() {
+ const programs = await listDirectory();
+ return NextResponse.json({
+ programs: programs.map((p) => ({
+ origin: p.origin,
+ merchant: p.merchant.name,
+ web: p.merchant.web ?? p.origin,
+ currency: p.merchant.currency,
+ terms: p.merchant.terms ?? null,
+ id: p.program.id,
+ title: p.program.title,
+ url: p.program.url ?? null,
+ join: p.program.join ?? null,
+ approval: p.program.approval,
+ pays: p.program.pays,
+ window: p.program.window ?? null,
+ attribution: p.program.attribution,
+ hold_days: p.program.hold_days ?? null,
+ payout: p.program.payout ?? null,
+ self: p.program.self,
+ status: p.program.status,
+ example_link: linkFor(p.program, "you", p.merchant.web ?? p.origin),
+ verified: p.verified,
+ read_at: p.fetchedAt,
+ })),
+ }, { headers: { "cache-control": "public, max-age=300" } });
+}
+
+export async function POST(req: NextRequest) {
+ const who = await resolveUser(req);
+ if (!who.ok) return NextResponse.json({ error: who.error }, { status: who.status });
+ let body: { url?: unknown } = {};
+ try {
+ body = (await req.json()) as { url?: unknown };
+ } catch {
+ /* empty */
+ }
+ const url = typeof body.url === "string" ? body.url.trim() : "";
+ if (!url) return NextResponse.json({ error: "Send { url } for the merchant." }, { status: 400 });
+ const out = await addOrRefreshProgram(url, who.user.id);
+ if (!out.ok) return NextResponse.json({ error: out.error }, { status: 422 });
+ return NextResponse.json({
+ origin: out.row.origin,
+ verified: out.row.verified,
+ merchant: out.row.descriptor?.merchant.name ?? null,
+ programs: out.row.descriptor?.programs.map((p) => ({ id: p.id, title: p.title, pays: p.pays, approval: p.approval, join: p.join ?? null, status: p.status })) ?? [],
+ warnings: out.warnings,
+ }, { status: 201 });
+}
diff --git a/app/api/affiliate/v1/token/route.ts b/app/api/affiliate/v1/token/route.ts
new file mode 100644
index 00000000..6d2e4c72
--- /dev/null
+++ b/app/api/affiliate/v1/token/route.ts
@@ -0,0 +1,15 @@
+// POST /api/affiliate/v1/token — rotate the caller's affiliate token. The
+// new one is shown once; the old one stops working at once.
+import { NextResponse, type NextRequest } from "next/server";
+import { resolveCaller } from "@/lib/affiliate/auth";
+import { rotateToken } from "@/lib/affiliate/memberships";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function POST(req: NextRequest) {
+ const caller = await resolveCaller(req);
+ if (!caller.ok) return NextResponse.json({ error: caller.error }, { status: caller.status });
+ const token = await rotateToken(caller.membership.id);
+ return NextResponse.json({ membership: caller.membership.id, token });
+}
diff --git a/app/api/coinpay/webhook/route.ts b/app/api/coinpay/webhook/route.ts
index 636eb28d..f7b34878 100644
--- a/app/api/coinpay/webhook/route.ts
+++ b/app/api/coinpay/webhook/route.ts
@@ -4,6 +4,7 @@ import { verifyWebhookSignature } from "@/lib/coinpay";
import { sendPurchaseReceiptEmail } from "@/lib/email";
import { env } from "@/lib/env";
import { findPack } from "@/lib/credits";
+import { recordPurchaseConversion } from "@/lib/affiliate/attribution";
export const runtime = "nodejs";
@@ -115,6 +116,11 @@ export async function POST(req: Request) {
void mailReceiptIfNeeded(svc, paymentId, payload).catch((err) => {
console.error("[coinpay] receipt mail failed", err);
});
+ // OpenAffiliate conversion, also off-band: it reads the buyer's attribution
+ // and is idempotent on the purchase, so a retry cannot record it twice.
+ void recordPurchaseConversion(svc, paymentId).catch((err) => {
+ console.error("[affiliate] conversion failed", err);
+ });
return NextResponse.json({ ok: true });
}
diff --git a/app/api/credits/create-invoice/route.ts b/app/api/credits/create-invoice/route.ts
index b9678c1f..3e33f0a8 100644
--- a/app/api/credits/create-invoice/route.ts
+++ b/app/api/credits/create-invoice/route.ts
@@ -5,6 +5,8 @@ import { serviceClient } from "@/lib/supabase/service";
import { findPack } from "@/lib/credits";
import { createPayment } from "@/lib/coinpay";
import { env } from "@/lib/env";
+import { attributeUser } from "@/lib/affiliate/attribution";
+import { cookieFromHeader } from "@/lib/affiliate/cookie";
export const runtime = "nodejs";
@@ -37,6 +39,14 @@ export async function POST(req: Request) {
return NextResponse.json({ ok: false, error: "Not authenticated." }, { status: 401 });
}
+ // OpenAffiliate: a buyer carrying the attribution cookie is bound to that
+ // affiliate now, so the completion webhook (which has no cookie) can find it.
+ try {
+ await attributeUser(user.id, cookieFromHeader(req.headers.get("cookie")));
+ } catch (err) {
+ console.error("[affiliate] attribute at invoice", err);
+ }
+
const svc = serviceClient();
const { data: purchase, error: insertErr } = await svc
.from("credit_purchases")
diff --git a/app/api/cron/affiliate/route.ts b/app/api/cron/affiliate/route.ts
new file mode 100644
index 00000000..c1cc584a
--- /dev/null
+++ b/app/api/cron/affiliate/route.ts
@@ -0,0 +1,43 @@
+// Hourly: approve conversions past their hold (or reverse the refunded),
+// deliver queued webhooks, pay on the weekly schedule, re-read stale
+// merchants and stale ledgers. Gated on CRON_SECRET like every cron route.
+import { NextResponse } from "next/server";
+import { serviceClient } from "@/lib/supabase/service";
+import { env } from "@/lib/env";
+import { approveDue } from "@/lib/affiliate/attribution";
+import { deliverDue } from "@/lib/affiliate/webhooks";
+import { runScheduledPayouts } from "@/lib/affiliate/payouts";
+import { refreshStale, syncStaleJoins } from "@/lib/affiliate/directory";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+export const maxDuration = 300;
+
+export async function GET(req: Request) {
+ return POST(req);
+}
+
+export async function POST(req: Request) {
+ const incoming = req.headers.get("x-cron-secret") ?? req.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
+ if (!env.cronSecret || incoming !== env.cronSecret) {
+ return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
+ }
+ const svc = serviceClient();
+ const out: Record = { ok: true };
+ const steps: Array<[string, () => Promise]> = [
+ ["approved", () => approveDue(svc)],
+ ["webhooks", () => deliverDue(svc)],
+ ["payouts", () => runScheduledPayouts(svc)],
+ ["directory", () => refreshStale(svc)],
+ ["joins", () => syncStaleJoins(svc)],
+ ];
+ for (const [name, run] of steps) {
+ try {
+ out[name] = await run();
+ } catch (err) {
+ out[name] = { error: err instanceof Error ? err.message : String(err) };
+ console.error(`[affiliate cron] ${name}`, err);
+ }
+ }
+ return NextResponse.json(out);
+}
diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts
index 52cb78ea..d06fce37 100644
--- a/app/api/mcp/route.ts
+++ b/app/api/mcp/route.ts
@@ -13,6 +13,7 @@ import { registerStatsTools } from "@/lib/mcp/stats";
import { registerAuditTools } from "@/lib/mcp/audits";
import { registerLeadTools } from "@/lib/mcp/leads";
import { registerAutoblogTools } from "@/lib/mcp/autoblog";
+import { registerAffiliateTools } from "@/lib/mcp/affiliate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -24,6 +25,7 @@ const handler = createMcpHandler(
registerAuditTools(server);
registerLeadTools(server);
registerAutoblogTools(server);
+ registerAffiliateTools(server);
},
{},
// The route is mounted at /api/mcp, so mcp-handler must derive its endpoint
diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts
index 511f7eae..763a0a1f 100644
--- a/app/auth/callback/route.ts
+++ b/app/auth/callback/route.ts
@@ -1,6 +1,8 @@
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { env } from "@/lib/env";
+import { attributeUser } from "@/lib/affiliate/attribution";
+import { cookieFromHeader } from "@/lib/affiliate/cookie";
// Resolve the URL Supabase should send users back to. Inside the Railway
// container Next.js's `request.url` carries the bind address (e.g.
@@ -32,6 +34,18 @@ export async function GET(request: Request) {
redirectUrl.searchParams.set("error", error.message);
return NextResponse.redirect(redirectUrl);
}
+ // OpenAffiliate: bind the signed-in user to the affiliate in their cookie.
+ try {
+ const oa = cookieFromHeader(request.headers.get("cookie"));
+ if (oa) {
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (user) await attributeUser(user.id, oa);
+ }
+ } catch (err) {
+ console.error("[affiliate] attribute at sign-in", err);
+ }
}
return NextResponse.redirect(new URL(next, origin));
diff --git a/cli/index.ts b/cli/index.ts
index 9ea518cb..f3cceafc 100644
--- a/cli/index.ts
+++ b/cli/index.ts
@@ -591,6 +591,163 @@ async function cmdDashboard(args: Args): Promise {
return 0;
}
+/** The request body `crawlproof affiliate join` sends, from its flags and positionals. Pure, for tests. */
+export function affiliateJoinBodyFromArgs(args: Args): Record {
+ const target = args.positional[1] ?? "";
+ const body: Record = { origin: target };
+ if (typeof args.flags.program === "string" && args.flags.program) body.program = args.flags.program;
+ if (typeof args.flags.code === "string" && args.flags.code) body.code = args.flags.code;
+ return body;
+}
+
+/** One line per conversion for the ledger table. Pure, for tests. */
+export function conversionLine(c: { at: string; event: string; amount: number; commission: number; status: string; held_until?: string; reason?: string }): string {
+ const when = String(c.at).slice(0, 10);
+ const hold = c.status === "pending" && c.held_until ? ` until ${String(c.held_until).slice(0, 10)}` : "";
+ const why = c.reason ? ` (${c.reason})` : "";
+ return `${when} ${c.event.padEnd(12)} $${c.amount.toFixed(2).padStart(8)} → $${c.commission.toFixed(2).padStart(7)} ${c.status}${hold}${why}`;
+}
+
+async function cmdAffiliate(args: Args): Promise {
+ const sub = args.positional[0] ?? "link";
+ const json = !!args.flags.json;
+
+ if (sub === "link" || sub === "me") {
+ const { status, json: me } = await apiCall(args, "GET", "/api/affiliate/v1/me");
+ if (status !== 200) throw new Error(String(me.error ?? `HTTP ${status}`));
+ if (json) {
+ process.stdout.write(`${JSON.stringify(me, null, 2)}\n`);
+ return 0;
+ }
+ const m = me.membership as Record;
+ const terms = me.terms as Record;
+ const ledger = me.ledger as Record;
+ const balance = ledger.balance as Record;
+ const clicks = ledger.clicks as Record;
+ console.log(`link ${m.link}`);
+ console.log(`code ${m.code}`);
+ console.log(`profile ${m.profile}`);
+ console.log(`pays ${terms.line}`);
+ console.log(`clicks ${clicks.total} all time, ${clicks.window} in the window`);
+ console.log(`balance pending $${balance.pending.toFixed(2)} approved $${balance.approved.toFixed(2)} paid $${balance.paid.toFixed(2)}`);
+ console.log(`payout ${m.pay_address ?? "(no address set: crawlproof affiliate pay --address 0x…)"}`);
+ return 0;
+ }
+
+ if (sub === "ledger") {
+ const since = typeof args.flags.since === "string" ? `?since=${encodeURIComponent(args.flags.since)}` : "";
+ const { status, json: ledger } = await apiCall(args, "GET", `/api/affiliate/v1/ledger${since}`);
+ if (status !== 200) throw new Error(String(ledger.error ?? `HTTP ${status}`));
+ if (json) {
+ process.stdout.write(`${JSON.stringify(ledger, null, 2)}\n`);
+ return 0;
+ }
+ const balance = ledger.balance as Record;
+ console.log(`pending $${balance.pending.toFixed(2)} approved $${balance.approved.toFixed(2)} paid $${balance.paid.toFixed(2)}`);
+ const rows = (ledger.conversions as Array[0]>) ?? [];
+ if (!rows.length) console.log("no conversions yet");
+ for (const c of rows) console.log(conversionLine(c));
+ const payouts = (ledger.payouts as Array<{ at: string; amount: number; status: string; tx: string | null }>) ?? [];
+ for (const p of payouts) console.log(`payout ${String(p.at).slice(0, 10)} $${p.amount.toFixed(2)} ${p.status}${p.tx ? ` ${p.tx}` : ""}`);
+ return 0;
+ }
+
+ if (sub === "pay") {
+ const address = typeof args.flags.address === "string" ? args.flags.address : args.positional[1];
+ if (!address) throw new Error("Usage: crawlproof affiliate pay --address 0x…");
+ const { status, json: me } = await apiCall(args, "POST", "/api/affiliate/v1/me", { pay: address });
+ if (status !== 200) throw new Error(String(me.error ?? `HTTP ${status}`));
+ console.log(`payout address set: ${(me.membership as Record).pay_address}`);
+ return 0;
+ }
+
+ if (sub === "webhook") {
+ const url = typeof args.flags.url === "string" ? args.flags.url : args.positional[1] ?? "";
+ if (!url && !args.flags.clear) throw new Error("Usage: crawlproof affiliate webhook | --clear");
+ const { status, json: me } = await apiCall(args, "POST", "/api/affiliate/v1/me", { webhook: args.flags.clear ? null : url });
+ if (status !== 200) throw new Error(String(me.error ?? `HTTP ${status}`));
+ console.log(`webhook: ${(me.membership as Record).webhook ?? "(none)"}`);
+ return 0;
+ }
+
+ if (sub === "token") {
+ if (!args.flags.yes) throw new Error("This replaces your affiliate token at once. Re-run with --yes.");
+ const { status, json: out } = await apiCall(args, "POST", "/api/affiliate/v1/token");
+ if (status !== 200) throw new Error(String(out.error ?? `HTTP ${status}`));
+ console.log(json ? JSON.stringify(out, null, 2) : `new affiliate token (shown once): ${out.token}`);
+ return 0;
+ }
+
+ if (sub === "payout") {
+ const { status, json: out } = await apiCall(args, "POST", "/api/affiliate/v1/payout");
+ if (status !== 200) throw new Error(String(out.error ?? `HTTP ${status}`));
+ const p = out.payout as Record;
+ console.log(json ? JSON.stringify(out, null, 2) : `sent $${Number(p.amount).toFixed(2)} ${p.tx ? `tx ${p.tx}` : `(${p.status})`}`);
+ return 0;
+ }
+
+ if (sub === "programs") {
+ const action = args.positional[1];
+ if (action === "add") {
+ const url = args.positional[2];
+ if (!url) throw new Error("Usage: crawlproof affiliate programs add ");
+ const { status, json: out } = await apiCall(args, "POST", "/api/affiliate/v1/programs", { url });
+ if (status !== 201) throw new Error(String(out.error ?? `HTTP ${status}`));
+ if (json) process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
+ else {
+ console.log(`${out.merchant} at ${out.origin} (${out.verified ? "verified" : "claimed"})`);
+ for (const p of out.programs as Array>) console.log(` ${p.id}: ${p.title} [${p.approval}]`);
+ for (const w of out.warnings as string[]) console.log(` warning: ${w}`);
+ }
+ return 0;
+ }
+ const { status, json: out } = await apiCall(args, "GET", "/api/affiliate/v1/programs");
+ if (status !== 200) throw new Error(String(out.error ?? `HTTP ${status}`));
+ if (json) process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
+ else {
+ const rows = out.programs as Array>;
+ if (!rows.length) console.log("no programs read yet: crawlproof affiliate programs add ");
+ for (const p of rows) {
+ const pays = (p.pays as Array<{ event: string; kind: string; value: number; months?: number }>)
+ .map((x) => `${x.kind === "percent" ? `${x.value}%` : `$${x.value}`}/${x.event}${x.months ? `×${x.months}mo` : ""}`)
+ .join(" ");
+ console.log(`${String(p.origin).replace(/^https?:\/\//, "").padEnd(28)} ${String(p.id).padEnd(14)} ${pays.padEnd(24)} window ${p.window ?? "?"}d hold ${p.hold_days ?? "?"}d ${p.approval}${p.verified ? "" : " (claimed)"}`);
+ }
+ }
+ return 0;
+ }
+
+ if (sub === "join") {
+ if (!args.positional[1]) throw new Error("Usage: crawlproof affiliate join [--program=id] [--code=yours]");
+ const { status, json: out } = await apiCall(args, "POST", "/api/affiliate/v1/programs/join", affiliateJoinBodyFromArgs(args));
+ if (status !== 200 && status !== 201) throw new Error(String(out.error ?? `HTTP ${status}`));
+ const j = out.join as Record;
+ console.log(json ? JSON.stringify(out, null, 2) : `${out.existing ? "already joined" : "joined"} ${j.origin} ${j.program}: ${j.status}${j.link ? `\nlink ${j.link}` : ""}`);
+ return 0;
+ }
+
+ if (sub === "joined") {
+ if (args.flags.sync) {
+ const { json: list } = await apiCall(args, "GET", "/api/affiliate/v1/joined");
+ for (const j of (list.joined as Array>) ?? []) await apiCall(args, "POST", `/api/affiliate/v1/joined/${j.id}/sync`);
+ }
+ const { status, json: out } = await apiCall(args, "GET", "/api/affiliate/v1/joined");
+ if (status !== 200) throw new Error(String(out.error ?? `HTTP ${status}`));
+ if (json) process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
+ else {
+ const rows = out.joined as Array>;
+ if (!rows.length) console.log("no programs joined yet: crawlproof affiliate join ");
+ for (const j of rows) {
+ const bal = (j.ledger as { balance?: Record } | null)?.balance;
+ console.log(`${String(j.origin).replace(/^https?:\/\//, "").padEnd(28)} ${String(j.program).padEnd(14)} ${String(j.status).padEnd(8)} ${bal ? `pending $${bal.pending.toFixed(2)} approved $${bal.approved.toFixed(2)} paid $${bal.paid.toFixed(2)}` : "no ledger yet"}${j.link ? ` ${j.link}` : ""}`);
+ }
+ }
+ return 0;
+ }
+
+ throw new Error(`unknown affiliate command: ${sub}. One of link, ledger, pay, payout, webhook, token, programs, join, joined.`);
+}
+
function help() {
console.log(`crawlproof — AEO audit CLI (stub)
@@ -655,6 +812,34 @@ COMMANDS
ads delete --yes
Remove it, metering included. Pause keeps the history.
+ affiliate [link] [--json]
+ Your affiliate link, code, profile URL, terms and balances in the
+ program CrawlProof runs (30% of a purchase within 30 days of a click,
+ paid in USDC on Polygon after a 30-day hold). Needs an API token.
+
+ affiliate ledger [--since=ISO] [--json]
+ Every conversion with its status, hold and reason, and every payout
+ with its tx. The same rows a third party reads with an oa_ token.
+
+ affiliate pay --address 0x… | affiliate payout
+ Set where the money goes; send the approved balance now.
+
+ affiliate webhook | affiliate webhook --clear
+ Where conversion, reversal and payout events are POSTed (signed).
+
+ affiliate token --yes
+ A new oa_ ledger token, shown once; the old one stops at once.
+
+ affiliate programs [add ] [--json]
+ The directory of other merchants' OpenAffiliate programs, read from
+ each merchant's own /.well-known/openaffiliate.json; add one by URL.
+
+ affiliate join [--program=id] [--code=yours] [--json]
+ Join a merchant's program with your CrawlProof profile and pay address.
+
+ affiliate joined [--sync] [--json]
+ The programs you have joined elsewhere, with their last ledger read.
+
slots create [--placement=inline] [--format=text_link] [--formats=a,b] [--inactive] [--no-tracking] [--json]
A publisher slot on a site you own, named by hostname or URL. The
site's project is found or created with the stats tracker on, and
@@ -695,7 +880,7 @@ ENV
ANTHROPIC_API_KEY Required for --engine=claude.
CRAWLPROOF_SITE_URL Override the API base URL for 'report', 'sweep', 'track', 'ads' and 'slots'.
CRAWLPROOF_PROJECT Default project UUID for 'track'.
- CRAWLPROOF_TOKEN API token (crp_…) for 'ads', 'slots', 'stats' and
+ CRAWLPROOF_TOKEN API token (crp_…) for 'ads', 'slots', 'affiliate', 'stats' and
'dashboard'; --token overrides. Falls back to the
'token' field of ~/.crawlproof.json.
COINPAY_SESSION_TOKEN CoinPay merchant JWT for the money half of
@@ -735,6 +920,8 @@ async function main() {
return await cmdAds(args);
case "slots":
return await cmdSlots(args);
+ case "affiliate":
+ return await cmdAffiliate(args);
case "stats":
return await cmdStats(args);
case "dashboard":
diff --git a/components/affiliate/controls.tsx b/components/affiliate/controls.tsx
new file mode 100644
index 00000000..b8569841
--- /dev/null
+++ b/components/affiliate/controls.tsx
@@ -0,0 +1,243 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import {
+ addProgram,
+ joinProgramAction,
+ requestAffiliatePayout,
+ rotateAffiliateToken,
+ savePayAddress,
+ saveWebhook,
+ syncJoinAction,
+} from "@/app/actions/affiliate";
+
+export function CopyField({ value, label }: { value: string; label: string }) {
+ const [copied, setCopied] = useState(false);
+ return (
+