From d55681f7ddc97fcd4b012c63e813b31ae558f0ad Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 02:44:29 +0000 Subject: [PATCH 1/3] Run an OpenAffiliate program, and join other merchants' programs from the same dashboard CrawlProof is the reference implementation of OpenAffiliate (logicsrc.com/docs/openaffiliate): one file a merchant serves about the commission it pays, and four calls that let a person or an agent earn it with no network in the money. The program we run: /.well-known/openaffiliate.json from one constants file (30% of a credits purchase within 30 days of a click, 30-day hold, USDC on Polygon weekly from $10, open approval, self-purchases refused). A navigation carrying ?oa= goes through /api/affiliate/v1/click, which records the click, sets the cookie and strips the parameter; an image, frame or script carrying it sets nothing. Sign-in and invoice creation bind the user to the affiliate; purchase completion records the conversion, pending, idempotent on the purchase; the hourly cron approves past the hold (or reverses refunded ones with a reason), delivers signed webhooks, pays on schedule through CoinPay with debit-first/put-back-on-failure RPCs. Join (public, with an OpenProfile.md), ledger (oa_ token, crp_ token or session), me, token rotation, payout, jwks. Every one of our users gets a membership on first use and an OpenProfile at /affiliate/u//openprofile.md, which is what they join other merchants with: a directory of descriptors read from each merchant's own origin, joins that hold the merchant's token for the user, ledgers synced daily and on demand, inbound webhooks. Surfaces: /dashboard/affiliate (link, balances, conversions, payouts, joined programs, directory), /affiliate, /affiliate/programs, /affiliate/terms, `crawlproof affiliate link|ledger|pay|payout|programs|join|joined`, seven MCP tools, docs/affiliate.md. Migration 20260913120000_openaffiliate.sql. The 2026-06 referral tables stay; nothing ever wrote a commission there. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CDEiDss9RWYibtmxSk5Gr2 --- app/(app)/dashboard/affiliate/page.tsx | 229 +++++++++ app/(app)/layout.tsx | 1 + app/(marketing)/affiliate/page.tsx | 99 ++++ app/(marketing)/affiliate/programs/page.tsx | 98 ++++ app/(marketing)/affiliate/terms/page.tsx | 49 ++ .../openaffiliate-jwks.json/route.ts | 13 + app/.well-known/openaffiliate.json/route.ts | 18 + app/actions/affiliate.ts | 100 ++++ app/affiliate/creatives.json/route.ts | 17 + .../u/[code]/openprofile.md/route.ts | 41 ++ app/api/affiliate/v1/click/route.ts | 53 +++ app/api/affiliate/v1/events/[join]/route.ts | 22 + app/api/affiliate/v1/join/route.ts | 74 +++ .../affiliate/v1/joined/[id]/sync/route.ts | 18 + app/api/affiliate/v1/joined/route.ts | 28 ++ app/api/affiliate/v1/ledger/route.ts | 23 + app/api/affiliate/v1/me/route.ts | 61 +++ app/api/affiliate/v1/payout/route.ts | 17 + app/api/affiliate/v1/programs/join/route.ts | 34 ++ app/api/affiliate/v1/programs/route.ts | 60 +++ app/api/affiliate/v1/token/route.ts | 15 + app/api/coinpay/webhook/route.ts | 6 + app/api/credits/create-invoice/route.ts | 10 + app/api/cron/affiliate/route.ts | 43 ++ app/api/mcp/route.ts | 2 + app/auth/callback/route.ts | 14 + cli/index.ts | 166 ++++++- components/affiliate/controls.tsx | 243 ++++++++++ docs/affiliate.md | 66 +++ lib/affiliate/attribution.ts | 211 +++++++++ lib/affiliate/auth.ts | 58 +++ lib/affiliate/client.ts | 206 ++++++++ lib/affiliate/cookie.ts | 42 ++ lib/affiliate/directory.ts | 329 +++++++++++++ lib/affiliate/memberships.ts | 341 ++++++++++++++ lib/affiliate/payouts.ts | 98 ++++ lib/affiliate/program.ts | 106 +++++ lib/affiliate/spec.ts | 439 ++++++++++++++++++ lib/affiliate/tokens.ts | 24 + lib/affiliate/webhooks.ts | 139 ++++++ lib/credits-finalize.ts | 5 + lib/env.ts | 6 + lib/mcp/affiliate.ts | 160 +++++++ proxy.ts | 22 + .../20260913120000_openaffiliate.sql | 370 +++++++++++++++ tests/affiliate-cli.test.ts | 19 + tests/affiliate-cookie.test.ts | 42 ++ tests/affiliate-spec.test.ts | 204 ++++++++ tests/affiliate-webhooks.test.ts | 29 ++ 49 files changed, 4469 insertions(+), 1 deletion(-) create mode 100644 app/(app)/dashboard/affiliate/page.tsx create mode 100644 app/(marketing)/affiliate/page.tsx create mode 100644 app/(marketing)/affiliate/programs/page.tsx create mode 100644 app/(marketing)/affiliate/terms/page.tsx create mode 100644 app/.well-known/openaffiliate-jwks.json/route.ts create mode 100644 app/.well-known/openaffiliate.json/route.ts create mode 100644 app/actions/affiliate.ts create mode 100644 app/affiliate/creatives.json/route.ts create mode 100644 app/affiliate/u/[code]/openprofile.md/route.ts create mode 100644 app/api/affiliate/v1/click/route.ts create mode 100644 app/api/affiliate/v1/events/[join]/route.ts create mode 100644 app/api/affiliate/v1/join/route.ts create mode 100644 app/api/affiliate/v1/joined/[id]/sync/route.ts create mode 100644 app/api/affiliate/v1/joined/route.ts create mode 100644 app/api/affiliate/v1/ledger/route.ts create mode 100644 app/api/affiliate/v1/me/route.ts create mode 100644 app/api/affiliate/v1/payout/route.ts create mode 100644 app/api/affiliate/v1/programs/join/route.ts create mode 100644 app/api/affiliate/v1/programs/route.ts create mode 100644 app/api/affiliate/v1/token/route.ts create mode 100644 app/api/cron/affiliate/route.ts create mode 100644 components/affiliate/controls.tsx create mode 100644 docs/affiliate.md create mode 100644 lib/affiliate/attribution.ts create mode 100644 lib/affiliate/auth.ts create mode 100644 lib/affiliate/client.ts create mode 100644 lib/affiliate/cookie.ts create mode 100644 lib/affiliate/directory.ts create mode 100644 lib/affiliate/memberships.ts create mode 100644 lib/affiliate/payouts.ts create mode 100644 lib/affiliate/program.ts create mode 100644 lib/affiliate/spec.ts create mode 100644 lib/affiliate/tokens.ts create mode 100644 lib/affiliate/webhooks.ts create mode 100644 lib/mcp/affiliate.ts create mode 100644 supabase/migrations/20260913120000_openaffiliate.sql create mode 100644 tests/affiliate-cli.test.ts create mode 100644 tests/affiliate-cookie.test.ts create mode 100644 tests/affiliate-spec.test.ts create mode 100644 tests/affiliate-webhooks.test.ts diff --git a/app/(app)/dashboard/affiliate/page.tsx b/app/(app)/dashboard/affiliate/page.tsx new file mode 100644 index 00000000..a9086ed0 --- /dev/null +++ b/app/(app)/dashboard/affiliate/page.tsx @@ -0,0 +1,229 @@ +import Link from "next/link"; +import { createClient } from "@/lib/supabase/server"; +import { ensureMembershipForUser, ledgerFor, profileUrlForMembership } from "@/lib/affiliate/memberships"; +import { listDirectory, listJoins } from "@/lib/affiliate/directory"; +import { HOLD_DAYS, PAYOUT_MIN_CENTS, WINDOW_DAYS, termsLine } from "@/lib/affiliate/program"; +import { AddProgramForm, CopyField, JoinButton, PayForm, PayoutButton, SyncButton, TokenButton, WebhookForm } from "@/components/affiliate/controls"; + +export const metadata = { title: "Affiliate" }; +export const dynamic = "force-dynamic"; + +const money = (n: number) => `$${n.toFixed(2)}`; + +function describePays(pays: Array<{ event: string; kind: string; value: number; months?: number }>): string { + return pays + .map((p) => `${p.kind === "percent" ? `${p.value}%` : `$${p.value}`} per ${p.event}${p.months ? ` for ${p.months} months` : ""}`) + .join(", "); +} + +export default async function AffiliatePage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return null; + + const membership = await ensureMembershipForUser({ id: user.id, email: user.email ?? null }); + if (!membership) { + return ( +
+

Affiliate

+

The affiliate program is not set up on this deployment yet.

+
+ ); + } + const [ledger, joins, directory] = await Promise.all([ledgerFor(membership), listJoins(user.id), listDirectory()]); + const joinedKeys = new Set(joins.filter((j) => j.status !== "refused" && j.status !== "ended").map((j) => `${j.origin.toLowerCase()}|${j.programId}`)); + const site = process.env.NEXT_PUBLIC_SITE_URL ?? ""; + const others = directory.filter((p) => !site || p.origin.replace(/\/$/, "") !== site.replace(/\/$/, "")); + + return ( +
+
+

Affiliate

+ + How it works + +
+

+ Earn {termsLine().toLowerCase()} And join any other merchant that runs an{" "} + + OpenAffiliate + {" "} + program, from here, with one profile. +

+ +
+

Your link

+

+ Any page on this site works with ?oa={membership.code} added. A click starts a {WINDOW_DAYS}-day window; a purchase in it is yours. +

+
+ +
+
+ + + + +
+
+ +
+

Getting paid

+

+ USDC on Polygon, weekly, once the approved balance reaches ${(PAYOUT_MIN_CENTS / 100).toFixed(0)}. Or send it now. +

+
+ + +
+
+ +
+

Conversions

+ {ledger.conversions.length === 0 ? ( +

None yet. Every purchase attributed to your link lands here with its status and hold.

+ ) : ( +
+ + + + + + + + + + + + {ledger.conversions.slice(0, 50).map((c) => ( + + + + + + + + ))} + +
WhenEventAmountCommissionStatus
{c.at.slice(0, 10)}{c.event}{money(c.amount)}{money(c.commission)} + {c.status} + {c.status === "pending" && c.held_until ? until {c.held_until.slice(0, 10)} : null} + {c.reason ? {c.reason} : null} +
+
+ )} + {ledger.payouts.length > 0 && ( +
    + {ledger.payouts.slice(0, 10).map((p) => ( +
  • + {p.at.slice(0, 10)}: {money(p.amount)} {p.status} + {p.tx ? ( + <> + {" "} + + tx + + + ) : null} +
  • + ))} +
+ )} +
+ +
+

Programs you have joined

+

+ Other merchants' programs, joined with your profile at {profileUrlForMembership(membership)}. Their ledgers are read daily and on demand. +

+ {joins.length === 0 ? ( +

None yet. Pick one below, or paste a merchant's URL.

+ ) : ( +
    + {joins.map((j) => { + const bal = (j.ledger?.balance ?? null) as { pending?: number; approved?: number; paid?: number } | null; + return ( +
  • +
    +
    +
    + {j.origin.replace(/^https?:\/\//, "")} · {j.programId} +
    +
    + {j.status} + {bal ? ` pending ${money(bal.pending ?? 0)} · approved ${money(bal.approved ?? 0)} · paid ${money(bal.paid ?? 0)}` : " no ledger read yet"} + {j.error ? ` · ${j.error}` : ""} +
    +
    + +
    + {j.link ? ( +
    + +
    + ) : null} +
  • + ); + })} +
+ )} +
+ +
+

Programs to join

+

+ Every merchant here was read from its own /.well-known/openaffiliate.json. Add one by URL. +

+
+ +
+ {others.length === 0 ? ( +

No other merchants read yet.

+ ) : ( +
    + {others.map((p) => ( +
  • +
    +
    + {p.merchant.name} · {p.program.title} + {p.verified ? verified : claimed} +
    +
    + {describePays(p.program.pays)} + {p.program.window ? ` · ${p.program.window}-day window` : ""} + {p.program.hold_days ? ` · ${p.program.hold_days}-day hold` : ""} + {` · ${p.program.approval}`} +
    +
    + +
  • + ))} +
+ )} +
+ +
+

For agents and scripts

+

+ 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. +

+
+ + +
+
+
+ ); +} + +function Stat({ label, value, hint }: { label: string; value: string; hint?: string }) { + return ( +
+
{label}
+
{value}
+ {hint ?
{hint}
: null} +
+ ); +} 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

+
    +
  1. + Add ?oa=yourcode to any CrawlProof page. The visitor lands on that page; the parameter is stripped and a {WINDOW_DAYS}-day window starts. +
  2. +
  3. They sign in and buy credits inside the window. The purchase is recorded to you, pending, with the day it will be approved.
  4. +
  5. After {HOLD_DAYS} days with no refund it is approved. A reversal always says why.
  6. +
  7. Weekly, the approved balance goes to your wallet. Or press pay out now.
  8. +
+

+ 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. +

+

+ Read the spec at{" "} + + logicsrc.com/openaffiliate + + . Full terms at /affiliate/terms. +

+
+
+ ); +} 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. +

+
+ + Join from your dashboard + + + Run one yourself + +
+ {programs.length === 0 ? ( +

No merchants read yet.

+ ) : ( +
    + {programs.map((p) => ( +
  • +
    +
    +

    + + {p.merchant.name} + {" "} + · {p.program.title} +

    +

    {p.origin.replace(/^https?:\/\//, "")}

    +
    +
    + {p.verified ? verified : claimed} + {p.program.approval} + {p.program.status !== "active" ? {p.program.status} : null} +
    +
    +
    +
    +
    Pays
    +
    {pays(p.program.pays)}
    +
    +
    +
    Window and hold
    +
    + {p.program.window ? `${p.program.window} days, ${p.program.attribution} touch` : "window unstated"} + {p.program.hold_days ? `; ${p.program.hold_days}-day hold` : "; hold unstated"} +
    +
    +
    +
    Payout
    +
    + {p.program.payout?.methods.length ? p.program.payout.methods.join(", ") : "unstated"} + {p.program.payout?.min !== undefined ? ` from ${p.program.payout.min} ${p.merchant.currency}` : ""} + {p.program.payout?.schedule ? `, ${p.program.payout.schedule.replace("_", " ")}` : ""} +
    +
    +
    +
    Link shape
    +
    {linkFor(p.program, "you", p.merchant.web ?? p.origin) ?? "unstated"}
    +
    +
    +

    + Read {p.fetchedAt ? new Date(p.fetchedAt).toUTCString() : "at an unknown time"}. + {p.merchant.terms ? ( + <> + {" "} + + Merchant's terms + + . + + ) : null} +

    +
  • + ))} +
+ )} +
+ ); +} 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. +

+
    +
  1. + 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. +
  2. +
  3. + 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. +
  4. +
  5. + 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. +
  6. +
  7. + 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. +
  8. +
  9. + 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. +
  10. +
  11. + 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. +
  12. +
  13. + 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. +
  14. +
  15. + 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. +
  16. +
+

+ 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..12fd9087 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 { COOKIE_NAME as OA_COOKIE } 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, req.headers.get("cookie")?.match(new RegExp(`(?:^|;\\s*)${OA_COOKIE}=([^;]+)`))?.[1] ?? null); + } 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..759ee0d0 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 { COOKIE_NAME as OA_COOKIE } 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 = request.headers.get("cookie")?.match(new RegExp(`(?:^|;\\s*)${OA_COOKIE}=([^;]+)`))?.[1] ?? null; + if (oa) { + const { + data: { user }, + } = await supabase.auth.getUser(); + if (user) await attributeUser(user.id, decodeURIComponent(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..ad6d394d 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -591,6 +591,146 @@ 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 === "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, programs, join, joined.`); +} + function help() { console.log(`crawlproof — AEO audit CLI (stub) @@ -655,6 +795,28 @@ 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 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 +857,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 +897,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 ( +
+ + {value} + + +
+ ); +} + +export function PayForm({ initial }: { initial: string }) { + const router = useRouter(); + const [pay, setPay] = useState(initial); + const [error, setError] = useState(null); + const [pending, start] = useTransition(); + return ( +
{ + e.preventDefault(); + setError(null); + start(async () => { + const res = await savePayAddress({ pay }); + if (!res.ok) return setError(res.error); + router.refresh(); + }); + }} + > + setPay(e.target.value)} + spellCheck={false} + /> + + {error &&

{error}

} +
+ ); +} + +export function WebhookForm({ initial }: { initial: string }) { + const router = useRouter(); + const [url, setUrl] = useState(initial); + const [error, setError] = useState(null); + const [pending, start] = useTransition(); + return ( +
{ + e.preventDefault(); + setError(null); + start(async () => { + const res = await saveWebhook({ webhook: url }); + if (!res.ok) return setError(res.error); + router.refresh(); + }); + }} + > + setUrl(e.target.value)} spellCheck={false} /> + + {error &&

{error}

} +
+ ); +} + +export function TokenButton({ prefix }: { prefix: string }) { + const [token, setToken] = useState(null); + const [error, setError] = useState(null); + const [pending, start] = useTransition(); + return ( +
+
+ {prefix}… + +
+ {token && ( +
+

Shown once. It reads your ledger at /api/affiliate/v1/ledger.

+ +
+ )} + {error &&

{error}

} +
+ ); +} + +export function PayoutButton({ approved, min, hasAddress }: { approved: number; min: number; hasAddress: boolean }) { + const router = useRouter(); + const [msg, setMsg] = useState(null); + const [pending, start] = useTransition(); + const can = hasAddress && approved >= min; + return ( +
+ + {msg && {msg}} +
+ ); +} + +export function AddProgramForm() { + const router = useRouter(); + const [url, setUrl] = useState(""); + const [msg, setMsg] = useState(null); + const [pending, start] = useTransition(); + return ( +
{ + e.preventDefault(); + setMsg(null); + start(async () => { + const res = await addProgram({ url }); + if (!res.ok) return setMsg(res.error); + setMsg(`Read ${res.origin}: ${res.programs} program${res.programs === 1 ? "" : "s"}.${res.warnings.length ? ` ${res.warnings.join(" ")}` : ""}`); + setUrl(""); + router.refresh(); + }); + }} + > + setUrl(e.target.value)} /> + + {msg &&

{msg}

} +
+ ); +} + +export function JoinButton({ origin, program, joined }: { origin: string; program: string; joined: boolean }) { + const router = useRouter(); + const [msg, setMsg] = useState(null); + const [pending, start] = useTransition(); + if (joined) return Joined; + return ( +
+ + {msg && {msg}} +
+ ); +} + +export function SyncButton({ id }: { id: string }) { + const router = useRouter(); + const [msg, setMsg] = useState(null); + const [pending, start] = useTransition(); + return ( +
+ + {msg && {msg}} +
+ ); +} diff --git a/docs/affiliate.md b/docs/affiliate.md new file mode 100644 index 00000000..3d0ef2cd --- /dev/null +++ b/docs/affiliate.md @@ -0,0 +1,66 @@ +# Affiliate program (OpenAffiliate) + +CrawlProof runs an [OpenAffiliate](https://logicsrc.com/docs/openaffiliate) program and is the spec's reference implementation: it serves its own descriptor, joins other merchants' programs from the same dashboard, and pays in USDC on Polygon through CoinPay. + +## The program we run + +Terms live in one place, `lib/affiliate/program.ts`, and every surface reads them: the descriptor at `/.well-known/openaffiliate.json`, the join answer, `/affiliate`, `/affiliate/terms`, the dashboard, the CLI and the MCP tools. Today: 30% of a credits purchase within 30 days of a click, 30-day hold, USDC on Polygon weekly from $10, open approval, self-purchases refused. + +How a click becomes money: + +1. `proxy.ts` sees a navigation carrying `?oa=` and redirects it to `/api/affiliate/v1/click`, which records the click, sets the `oa` cookie (`.`, 30 days) and redirects back to the same path without the parameter. Only a navigation counts (`Sec-Fetch-Dest: document`); an image, frame or script carrying the parameter sets nothing. +2. When the visitor signs in (`app/auth/callback`) or starts a purchase (`/api/credits/create-invoice`), `attributeUser` binds the user to the affiliate in `affiliate_attributions` (last touch inside the window; never the affiliate's own account). +3. When the purchase completes (`completePurchase` and the CoinPay webhook), `recordPurchaseConversion` inserts an `affiliate_conversions` row, `pending`, with `held_until` 30 days out. Idempotent on the purchase id. +4. Hourly, `/api/cron/affiliate` approves conversions past their hold (or reverses refunded ones with a reason), delivers queued webhooks, pays every membership whose approved balance is over the minimum and whose last payout is a week old, re-reads stale merchants, and re-reads stale ledgers. +5. A payout is `affiliate_request_payout` (conversions flip to `paid` under a lock) followed by `createCryptoPayout`; a failed send calls `affiliate_fail_payout` and the conversions go back to `approved`. + +### Routes + +| Route | Auth | What | +|---|---|---| +| `GET /.well-known/openaffiliate.json` | none | the descriptor | +| `GET /.well-known/openaffiliate-jwks.json` | none | the webhook signing key (empty set when `OPENAFFILIATE_SIGNING_KEY` is unset) | +| `POST /api/affiliate/v1/join` | none, or session, or `crp_` | join: `{ profile, pay?, webhook?, code? }` → membership, code, link, token (once), ledger URL, pays | +| `GET /api/affiliate/v1/ledger?since=` | `oa_`, `crp_` or session | the caller's ledger, spec shape | +| `GET/POST /api/affiliate/v1/me` | `crp_` or session | membership + terms + ledger; POST `{ pay?, webhook? }` | +| `POST /api/affiliate/v1/token` | `crp_` or session | rotate the `oa_` token | +| `POST /api/affiliate/v1/payout` | any of the three | send the approved balance now | +| `GET /api/affiliate/v1/click?oa=&to=` | none | the click landing (the middleware sends navigations here) | +| `GET/POST /api/affiliate/v1/programs` | GET none; POST `crp_` or session | the directory; POST `{ url }` reads a merchant | +| `POST /api/affiliate/v1/programs/join` | `crp_` or session | join another merchant: `{ origin, program?, code? }` | +| `GET /api/affiliate/v1/joined` | `crp_` or session | joined programs with last ledgers | +| `POST /api/affiliate/v1/joined/:id/sync` | `crp_` or session | read that ledger now | +| `POST /api/affiliate/v1/events/:join` | the join id | inbound webhook from a merchant we joined | +| `POST /api/cron/affiliate` | `CRON_SECRET` | the hourly sweep | +| `GET /affiliate/u/:code/openprofile.md` | none | an OpenProfile.md for one of our affiliates | +| `GET /affiliate/creatives.json` | none | artwork an affiliate may use as given | + +Three credentials reach the routes: `Authorization: Bearer oa_…` (an affiliate's ledger token, what the spec promises), `Bearer crp_…` (a CrawlProof API token, mapped to that user's membership) and the session cookie. Our own users get a membership on first use; outside parties join with a profile. + +### Joining other programs + +The dashboard, `crawlproof affiliate join ` and the `affiliate_join` MCP tool join a merchant on the user's behalf with the user's own profile (`/affiliate/u//openprofile.md`), pay address and a webhook back to `/api/affiliate/v1/events/`. The token the merchant hands back is held in `affiliate_joins.token`, readable only through the owner. + +### CLI + +``` +crawlproof affiliate [link] [--json] +crawlproof affiliate ledger [--since=ISO] [--json] +crawlproof affiliate pay --address 0x… +crawlproof affiliate payout +crawlproof affiliate programs [add ] +crawlproof affiliate join [--program=id] [--code=yours] +crawlproof affiliate joined [--sync] +``` + +### Env + +- `OPENAFFILIATE_SIGNING_KEY`: 32 random bytes, base64url, the Ed25519 seed for webhook signatures. Optional. `openssl rand -base64 32 | tr '+/' '-_' | tr -d '='`. +- `SP_TOKEN_PEPPER`: already required; peppers `oa_` tokens the same way as `crp_` ones. +- `CRON_SECRET`, `COINPAY_API_KEY`: already required; the sweep and payouts use them. + +### Migration + +`supabase/migrations/20260913120000_openaffiliate.sql`, applied one file at a time via the Supabase MCP. Tables `affiliate_memberships`, `affiliate_clicks`, `affiliate_attributions`, `affiliate_conversions`, `affiliate_payouts`, `affiliate_events`, `affiliate_programs`, `affiliate_joins`; RPCs `affiliate_request_payout`, `affiliate_fail_payout`; cron `crawlproof-affiliate` at 23 past every hour. + +The 2026-06 `referral_codes` / `referral_usages` tables and the `@profullstack/stack/referrals` cookie stay as they were; nothing in the repo ever wrote a commission there. diff --git a/lib/affiliate/attribution.ts b/lib/affiliate/attribution.ts new file mode 100644 index 00000000..b5d87900 --- /dev/null +++ b/lib/affiliate/attribution.ts @@ -0,0 +1,211 @@ +// Clicks, attributions and conversions on the program we run. +// +// The chain is: a navigation with ?oa= → affiliate_clicks + the `oa` cookie +// (click route) → affiliate_attributions when the user signs in or starts a +// purchase (attributeUser) → affiliate_conversions when the purchase +// completes (recordPurchaseConversion, from the CoinPay webhook, which has no +// cookie and reads the attribution instead) → approved when the hold passes +// (approveDue, from the cron) → paid by lib/affiliate/payouts.ts. + +import crypto from "node:crypto"; +import { serviceClient } from "../supabase/service"; +import { env } from "../env"; +import { decodeCookie, expiresAt, withinWindow } from "./cookie"; +import { HOLD_DAYS, WINDOW_DAYS } from "./program"; +import { commissionCents } from "./spec"; +import { membershipByCode, membershipById, type Membership } from "./memberships"; +import { queueEvent } from "./webhooks"; + +type Svc = ReturnType; + +export function ipHash(ip: string | null | undefined): string | null { + if (!ip) return null; + return crypto.createHash("sha256").update(`${ip}${env.ipHashSalt}`).digest("hex").slice(0, 32); +} + +export async function recordClick(input: { + membership: Membership; + landing: string | null; + referrer: string | null; + ip: string | null; + userAgent: string | null; +}): Promise { + await serviceClient().from("affiliate_clicks").insert({ + membership_id: input.membership.id, + landing: input.landing?.slice(0, 500) ?? null, + referrer: input.referrer?.slice(0, 500) ?? null, + ip_hash: ipHash(input.ip), + user_agent: input.userAgent?.slice(0, 300) ?? null, + }); +} + +/** + * Bind a signed-in user to the affiliate in their cookie. Last-touch: a newer + * click replaces an older attribution while both are inside the window. The + * affiliate's own account is never attributed to itself. + */ +export async function attributeUser(userId: string, cookieValue: string | null | undefined): Promise { + const parsed = decodeCookie(cookieValue); + if (!parsed) return null; + if (!withinWindow(parsed.clickedAt, WINDOW_DAYS)) return null; + const membership = await membershipByCode(parsed.code); + if (!membership || membership.status !== "active") return null; + if (membership.ownerId === userId) return null; + + const svc = serviceClient(); + const { data: current } = await svc + .from("affiliate_attributions") + .select("clicked_at") + .eq("user_id", userId) + .maybeSingle(); + if (current && new Date(current.clicked_at).getTime() >= parsed.clickedAt.getTime()) return membership; + + await svc.from("affiliate_attributions").upsert( + { + user_id: userId, + membership_id: membership.id, + code: membership.code, + clicked_at: parsed.clickedAt.toISOString(), + expires_at: expiresAt(parsed.clickedAt, WINDOW_DAYS).toISOString(), + set_at: new Date().toISOString(), + }, + { onConflict: "user_id" }, + ); + return membership; +} + +export async function attributionFor(svc: Svc, userId: string): Promise<{ membershipId: string; code: string; clickedAt: string; expiresAt: string } | null> { + const { data } = await svc + .from("affiliate_attributions") + .select("membership_id, code, clicked_at, expires_at") + .eq("user_id", userId) + .maybeSingle(); + if (!data) return null; + return { membershipId: data.membership_id, code: data.code, clickedAt: data.clicked_at, expiresAt: data.expires_at }; +} + +/** + * Record the conversion for a completed credits purchase, if its buyer is + * attributed to an affiliate. Idempotent on (event, order_ref). Best-effort + * from the caller's point of view: it never throws, because a purchase must + * complete whether or not the affiliate row lands. + */ +export async function recordPurchaseConversion(svc: Svc, paymentId: string): Promise<{ recorded: boolean; reason?: string }> { + try { + const { data: purchase } = await svc + .from("credit_purchases") + .select("id, owner_id, amount_cents, status, completed_at") + .eq("coinpay_payment_id", paymentId) + .maybeSingle(); + if (!purchase || purchase.status !== "complete") return { recorded: false, reason: "not complete" }; + + const attribution = await attributionFor(svc, purchase.owner_id); + if (!attribution) return { recorded: false, reason: "no attribution" }; + const completedAt = new Date(purchase.completed_at ?? Date.now()); + if (completedAt.getTime() > new Date(attribution.expiresAt).getTime()) return { recorded: false, reason: "window closed" }; + + const membership = await membershipById(attribution.membershipId); + if (!membership || membership.status !== "active") return { recorded: false, reason: "membership inactive" }; + + const self = membership.ownerId === purchase.owner_id; + const commission = self ? 0 : commissionCents(membership.terms, "sale", purchase.amount_cents); + const now = new Date(); + const row = { + membership_id: membership.id, + event: "sale", + order_ref: purchase.id, + customer_id: purchase.owner_id, + amount_cents: purchase.amount_cents, + commission_cents: commission, + status: self ? "reversed" : "pending", + reason: self ? "self-purchase: the program pays nothing on the affiliate's own account" : null, + held_until: self ? null : new Date(now.getTime() + HOLD_DAYS * 86_400_000).toISOString(), + }; + const { data, error } = await svc + .from("affiliate_conversions") + .upsert(row, { onConflict: "event,order_ref", ignoreDuplicates: true }) + .select("id, created_at") + .maybeSingle(); + if (error) { + console.error("[affiliate] conversion insert failed", error); + return { recorded: false, reason: error.message }; + } + if (!data) return { recorded: false, reason: "already recorded" }; + + await queueEvent(membership, "conversion.recorded", { + conversion: { + id: data.id, + event: "sale", + amount: purchase.amount_cents / 100, + commission: commission / 100, + status: row.status, + ...(row.held_until ? { held_until: row.held_until } : {}), + ...(row.reason ? { reason: row.reason } : {}), + }, + }); + return { recorded: true }; + } catch (err) { + console.error("[affiliate] recordPurchaseConversion", err); + return { recorded: false, reason: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * Approve every pending conversion past its hold, unless the purchase was + * refunded in the meantime, in which case it is reversed with that reason. + */ +export async function approveDue(svc: Svc, now = new Date()): Promise<{ approved: number; reversed: number }> { + const { data: due } = await svc + .from("affiliate_conversions") + .select("id, membership_id, order_ref, amount_cents, commission_cents, event") + .eq("status", "pending") + .lte("held_until", now.toISOString()) + .limit(500); + let approved = 0; + let reversed = 0; + for (const c of (due ?? []) as Array<{ id: string; membership_id: string; order_ref: string; amount_cents: number; commission_cents: number; event: string }>) { + const { data: purchase } = await svc.from("credit_purchases").select("status").eq("id", c.order_ref).maybeSingle(); + const refunded = purchase?.status === "refunded" || purchase?.status === "failed"; + const patch = refunded + ? { status: "reversed", reason: `refunded ${now.toISOString().slice(0, 10)}`, updated_at: now.toISOString() } + : { status: "approved", updated_at: now.toISOString() }; + const { error } = await svc.from("affiliate_conversions").update(patch).eq("id", c.id).eq("status", "pending"); + if (error) continue; + refunded ? reversed++ : approved++; + const membership = await membershipById(c.membership_id); + if (membership) { + await queueEvent(membership, refunded ? "conversion.reversed" : "conversion.approved", { + conversion: { + id: c.id, + event: c.event, + amount: c.amount_cents / 100, + commission: c.commission_cents / 100, + status: patch.status, + ...("reason" in patch ? { reason: patch.reason } : {}), + }, + }); + } + } + return { approved, reversed }; +} + +/** Take a conversion back, with the reason the spec requires. */ +export async function reverseConversion(svc: Svc, conversionId: string, reason: string): Promise { + const why = reason.trim(); + if (!why) throw new Error("A reversal needs a reason."); + const { data } = await svc + .from("affiliate_conversions") + .update({ status: "reversed", reason: why, updated_at: new Date().toISOString() }) + .eq("id", conversionId) + .in("status", ["pending", "approved"]) + .select("id, membership_id, event, amount_cents, commission_cents") + .maybeSingle(); + if (!data) return false; + const membership = await membershipById(data.membership_id); + if (membership) { + await queueEvent(membership, "conversion.reversed", { + conversion: { id: data.id, event: data.event, amount: data.amount_cents / 100, commission: data.commission_cents / 100, status: "reversed", reason: why }, + }); + } + return true; +} diff --git a/lib/affiliate/auth.ts b/lib/affiliate/auth.ts new file mode 100644 index 00000000..64799eee --- /dev/null +++ b/lib/affiliate/auth.ts @@ -0,0 +1,58 @@ +// Who is asking. Three credentials reach the affiliate routes: +// Authorization: Bearer oa_… an affiliate's ledger token (outside party or ours) +// Authorization: Bearer crp_… a CrawlProof API token → that user's membership +// the session cookie a signed-in user → their membership +// The first is what the spec promises; the other two let our own users and +// their agents use the same routes without minting a second credential. + +import type { NextRequest } from "next/server"; +import { authenticateBearer } from "../sp/apiAuth"; +import { createClient } from "../supabase/server"; +import { serviceClient } from "../supabase/service"; +import { isAffiliateTokenShape } from "./tokens"; +import { ensureMembershipForUser, membershipByToken, membershipForUser, type Membership } from "./memberships"; + +export type Caller = + | { ok: true; membership: Membership; via: "token" | "api" | "session"; user: { id: string; email: string | null } | null } + | { ok: false; status: number; error: string }; + +async function userFromRequest(req: NextRequest): Promise<{ id: string; email: string | null } | { status: number; error: string }> { + const header = req.headers.get("authorization") ?? ""; + if (/^bearer\s+/i.test(header)) { + const auth = await authenticateBearer(req); + if (!auth.ok) return { status: auth.status, error: auth.error }; + const { data } = await serviceClient().from("profiles").select("email").eq("id", auth.userId).maybeSingle(); + return { id: auth.userId, email: data?.email ?? null }; + } + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { status: 401, error: "Sign in, send an API token, or send your affiliate token." }; + return { id: user.id, email: user.email ?? null }; +} + +/** The signed-in user or API-token user, without a membership. */ +export async function resolveUser(req: NextRequest): Promise<{ ok: true; user: { id: string; email: string | null } } | { ok: false; status: number; error: string }> { + const u = await userFromRequest(req); + if ("status" in u) return { ok: false, status: u.status, error: u.error }; + return { ok: true, user: u }; +} + +/** The caller's membership in our program, created on first use for our own users. */ +export async function resolveCaller(req: NextRequest, opts: { create?: boolean } = { create: true }): Promise { + const header = req.headers.get("authorization") ?? ""; + const bearer = header.replace(/^bearer\s+/i, "").trim(); + if (bearer && isAffiliateTokenShape(bearer)) { + const membership = await membershipByToken(bearer); + if (!membership) return { ok: false, status: 401, error: "Unknown affiliate token." }; + return { ok: true, membership, via: "token", user: null }; + } + const u = await userFromRequest(req); + if ("status" in u) return { ok: false, status: u.status, error: u.error }; + const m = opts.create === false + ? await membershipForUser(u.id) + : await ensureMembershipForUser({ id: u.id, email: u.email }); + if (!m) return { ok: false, status: 404, error: "No affiliate membership yet." }; + return { ok: true, membership: m, via: bearer ? "api" : "session", user: u }; +} diff --git a/lib/affiliate/client.ts b/lib/affiliate/client.ts new file mode 100644 index 00000000..35b608cc --- /dev/null +++ b/lib/affiliate/client.ts @@ -0,0 +1,206 @@ +// The affiliate side of the spec: find a merchant's descriptor, join a +// program, read a ledger, and read an OpenProfile.md. Plain fetch with +// timeouts and size caps; no Supabase here, so the CLI can use it directly. + +import { + WELL_KNOWN_PATH, + isVerifiedOrigin, + parseDescriptor, + readProfile, + type Descriptor, + type Pays, + type ProfileFacts, + type Program, +} from "./spec"; + +const UA = "openaffiliate-reader (crawlproof.com)"; +const MAX_BYTES = 512 * 1024; + +async function fetchText(url: string, accept: string): Promise<{ ok: true; text: string; headers: Headers; url: string } | { ok: false; error: string; status?: number }> { + try { + const res = await fetch(url, { + headers: { accept, "user-agent": UA }, + signal: AbortSignal.timeout(10_000), + redirect: "follow", + }); + if (!res.ok) return { ok: false, error: `HTTP ${res.status}`, status: res.status }; + const buf = await res.arrayBuffer(); + if (buf.byteLength > MAX_BYTES) return { ok: false, error: "response over 512 KB" }; + return { ok: true, text: new TextDecoder().decode(buf), headers: res.headers, url: res.url || url }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export type Discovered = { + ok: true; + descriptor: Descriptor; + fetchedFrom: string; + origin: string; + verified: boolean; + warnings: string[]; + raw: unknown; +}; +export type DiscoverResult = Discovered | { ok: false; error: string }; + +function originOf(input: string): string | null { + try { + const u = new URL(/^https?:\/\//i.test(input) ? input : `https://${input}`); + return u.origin; + } catch { + return null; + } +} + +function linkRelFromHtml(html: string, base: string): string | null { + const m = html.match(/]*rel=["']?openaffiliate["']?[^>]*>/i); + if (!m) return null; + const href = m[0].match(/href=["']([^"']+)["']/i)?.[1]; + if (!href) return null; + try { + return new URL(href, base).toString(); + } catch { + return null; + } +} + +function linkRelFromHeader(header: string | null, base: string): string | null { + if (!header) return null; + for (const part of header.split(",")) { + const m = part.match(/<([^>]+)>\s*;\s*rel=["']?openaffiliate["']?/i); + if (m) { + try { + return new URL(m[1], base).toString(); + } catch { + return null; + } + } + } + return null; +} + +async function tryDescriptor(url: string, origin: string): Promise { + const got = await fetchText(url, "application/json"); + if (!got.ok) return { ok: false, error: `${url}: ${got.error}` }; + let raw: unknown; + try { + raw = JSON.parse(got.text); + } catch { + return { ok: false, error: `${url} is not JSON.` }; + } + const parsed = parseDescriptor(raw); + if (!parsed.ok) return { ok: false, error: `${url}: ${parsed.error}` }; + return { + ok: true, + descriptor: parsed.descriptor, + fetchedFrom: got.url, + origin, + verified: isVerifiedOrigin(got.url, parsed.descriptor.merchant.web ?? origin), + warnings: parsed.warnings, + raw, + }; +} + +/** + * Find a merchant's descriptor the three ways the spec names, in order: + * /.well-known/openaffiliate.json, a rel="openaffiliate" link or header on + * the home page, or the URL given directly when it is a JSON file. + */ +export async function discoverDescriptor(input: string): Promise { + const origin = originOf(input); + if (!origin) return { ok: false, error: "That is not a URL." }; + const direct = /^https?:\/\//i.test(input) && /\.json(\?|$)/i.test(input) ? input : null; + + const wellKnown = await tryDescriptor(`${origin}${WELL_KNOWN_PATH}`, origin); + if (wellKnown.ok) return wellKnown; + + const home = await fetchText(`${origin}/`, "text/html"); + if (home.ok) { + const rel = linkRelFromHeader(home.headers.get("link"), origin) ?? linkRelFromHtml(home.text, origin); + if (rel) { + const viaRel = await tryDescriptor(rel, origin); + if (viaRel.ok) return viaRel; + } + } + + if (direct) { + const given = await tryDescriptor(direct, origin); + if (given.ok) return { ...given, verified: false }; + } + return { ok: false, error: `No OpenAffiliate descriptor at ${origin}${WELL_KNOWN_PATH}, no rel="openaffiliate" link on its home page.` }; +} + +export type JoinAnswer = { + membership: string; + program: string; + status: "active" | "pending" | "refused"; + code?: string; + link?: string; + token?: string; + ledger?: string; + pays?: Pays[]; +}; + +export async function joinProgram( + program: Program, + body: { program?: string; profile: string; pay?: string; webhook?: string; code?: string }, +): Promise<{ ok: true; answer: JoinAnswer; raw: Record } | { ok: false; error: string }> { + if (!program.join) return { ok: false, error: `${program.title} has no join URL; join it by hand at ${program.url ?? "the merchant"}.` }; + if (program.status !== "active") return { ok: false, error: `${program.title} is ${program.status} and takes no joins.` }; + let res: Response; + try { + res = await fetch(program.join, { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json", "user-agent": UA }, + body: JSON.stringify({ program: program.id, ...body }), + signal: AbortSignal.timeout(15_000), + }); + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + let raw: Record; + try { + raw = (await res.json()) as Record; + } catch { + return { ok: false, error: `The merchant answered ${res.status} without JSON.` }; + } + if (!res.ok) return { ok: false, error: String(raw.error ?? `The merchant answered ${res.status}.`) }; + const status = raw.status === "pending" || raw.status === "refused" ? raw.status : "active"; + if (typeof raw.membership !== "string") return { ok: false, error: "The merchant's answer has no membership id." }; + return { + ok: true, + answer: { + membership: raw.membership, + program: typeof raw.program === "string" ? raw.program : program.id, + status, + code: typeof raw.code === "string" ? raw.code : undefined, + link: typeof raw.link === "string" ? raw.link : undefined, + token: typeof raw.token === "string" ? raw.token : undefined, + ledger: typeof raw.ledger === "string" ? raw.ledger : program.ledger, + pays: Array.isArray(raw.pays) ? (raw.pays as Pays[]) : program.pays, + }, + raw, + }; +} + +export async function readLedger(ledgerUrl: string, token: string, since?: string): Promise<{ ok: true; ledger: Record } | { ok: false; error: string; status?: number }> { + const u = new URL(ledgerUrl); + if (since) u.searchParams.set("since", since); + try { + const res = await fetch(u.toString(), { + headers: { accept: "application/json", authorization: `Bearer ${token}`, "user-agent": UA }, + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) return { ok: false, error: `HTTP ${res.status}`, status: res.status }; + return { ok: true, ledger: (await res.json()) as Record }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function fetchProfile(url: string): Promise<{ ok: true; facts: ProfileFacts; markdown: string } | { ok: false; error: string }> { + const got = await fetchText(url, "text/markdown, text/plain;q=0.9, */*;q=0.1"); + if (!got.ok) return { ok: false, error: got.error }; + if (/^\s*.`. Set by the click +// route on a navigation that carried ?oa=, read when the user signs in or +// starts a purchase. Pure, so the middleware and tests can share it. + +export const COOKIE_NAME = "oa"; + +export type Attribution = { code: string; clickedAt: Date }; + +export function encodeCookie(a: Attribution): string { + return `${a.code}.${Math.floor(a.clickedAt.getTime() / 1000)}`; +} + +export function decodeCookie(value: string | null | undefined): Attribution | null { + if (!value) return null; + const dot = value.lastIndexOf("."); + if (dot <= 0) return null; + const code = value.slice(0, dot); + const secs = Number(value.slice(dot + 1)); + if (!/^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/.test(code) || !Number.isFinite(secs) || secs <= 0) return null; + return { code, clickedAt: new Date(secs * 1000) }; +} + +export function expiresAt(clickedAt: Date, windowDays: number): Date { + return new Date(clickedAt.getTime() + windowDays * 86_400_000); +} + +export function withinWindow(clickedAt: Date, windowDays: number, now = new Date()): boolean { + return now.getTime() <= expiresAt(clickedAt, windowDays).getTime() && clickedAt.getTime() <= now.getTime() + 60_000; +} + +/** + * Whether a request is a top-level navigation. Only a navigation sets + * attribution (spec, "Links and attribution" rule 1): an image, frame, + * script or prefetch that carries ?oa= sets nothing. + */ +export function isNavigation(headers: { get(name: string): string | null }): boolean { + const dest = headers.get("sec-fetch-dest"); + const mode = headers.get("sec-fetch-mode"); + if (dest || mode) return dest === "document" && (mode === "navigate" || mode === null); + const accept = headers.get("accept") ?? ""; + return accept.includes("text/html"); +} diff --git a/lib/affiliate/directory.ts b/lib/affiliate/directory.ts new file mode 100644 index 00000000..6e6f1512 --- /dev/null +++ b/lib/affiliate/directory.ts @@ -0,0 +1,329 @@ +// The affiliate side, persisted: a directory of merchants whose descriptors +// we have read, and our users' joins into their programs. We join with the +// user's own profile (served at /affiliate/u//openprofile.md) and hold +// the merchant's token for the user, readable only through the owner. + +import { serviceClient } from "../supabase/service"; +import { env } from "../env"; +import { discoverDescriptor, joinProgram, readLedger } from "./client"; +import { ensureMembershipForUser, profileUrlForMembership } from "./memberships"; +import { parseDescriptor, type Descriptor, type Program } from "./spec"; + +type Svc = ReturnType; + +export type DirectoryRow = { + id: string; + origin: string; + descriptor: Descriptor | null; + verified: boolean; + fetchedAt: string | null; + error: string | null; +}; + +export type DirectoryProgram = { + origin: string; + merchant: Descriptor["merchant"]; + program: Program; + verified: boolean; + fetchedAt: string | null; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function toRow(r: any): DirectoryRow { + const parsed = r.descriptor ? parseDescriptor(r.descriptor) : null; + return { + id: r.id, + origin: r.origin, + descriptor: parsed?.ok ? parsed.descriptor : null, + verified: !!r.verified, + fetchedAt: r.fetched_at ?? null, + error: r.error ?? null, + }; +} + +export async function listDirectory(): Promise { + const { data } = await serviceClient() + .from("affiliate_programs") + .select("id, origin, descriptor, verified, fetched_at, error") + .not("descriptor", "is", null) + .order("fetched_at", { ascending: false }) + .limit(500); + const out: DirectoryProgram[] = []; + for (const raw of data ?? []) { + const row = toRow(raw); + if (!row.descriptor) continue; + for (const program of row.descriptor.programs) { + if (program.status === "closed") continue; + out.push({ origin: row.origin, merchant: row.descriptor.merchant, program, verified: row.verified, fetchedAt: row.fetchedAt }); + } + } + // Verified above claimed (spec, "Directories" rule 6), then newest read first. + return out.sort((a, b) => Number(b.verified) - Number(a.verified)); +} + +export async function directoryRow(origin: string): Promise { + const { data } = await serviceClient() + .from("affiliate_programs") + .select("id, origin, descriptor, verified, fetched_at, error") + .ilike("origin", origin) + .maybeSingle(); + return data ? toRow(data) : null; +} + +/** Read a merchant and keep what was found. A failed read keeps the last good descriptor and records the error. */ +export async function addOrRefreshProgram(input: string, addedBy: string | null): Promise<{ ok: true; row: DirectoryRow; warnings: string[] } | { ok: false; error: string }> { + const found = await discoverDescriptor(input); + const svc = serviceClient(); + const now = new Date().toISOString(); + if (!found.ok) { + let origin: string | null = null; + try { + origin = new URL(/^https?:\/\//i.test(input) ? input : `https://${input}`).origin; + } catch { + /* not a URL */ + } + if (origin) { + await svc + .from("affiliate_programs") + .update({ error: found.error, updated_at: now }) + .ilike("origin", origin); + } + return { ok: false, error: found.error }; + } + const { data, error } = await svc + .from("affiliate_programs") + .upsert( + { + origin: found.origin, + descriptor: found.raw, + verified: found.verified, + fetched_at: now, + error: null, + added_by: addedBy, + updated_at: now, + }, + { onConflict: "origin" }, + ) + .select("id, origin, descriptor, verified, fetched_at, error") + .single(); + if (error || !data) { + // The unique index is on lower(origin), which upsert cannot target; fall back to update-or-insert. + const existing = await directoryRow(found.origin); + if (existing) { + await svc + .from("affiliate_programs") + .update({ descriptor: found.raw, verified: found.verified, fetched_at: now, error: null, updated_at: now }) + .eq("id", existing.id); + const row = await directoryRow(found.origin); + return row ? { ok: true, row, warnings: found.warnings } : { ok: false, error: "Could not store the program." }; + } + return { ok: false, error: error?.message ?? "Could not store the program." }; + } + return { ok: true, row: toRow(data), warnings: found.warnings }; +} + +/** Re-read every merchant not read in the last day (spec, "Directories" rule 1). */ +export async function refreshStale(svc: Svc, now = new Date()): Promise<{ refreshed: number; failed: number }> { + const { data } = await svc + .from("affiliate_programs") + .select("origin") + .or(`fetched_at.is.null,fetched_at.lt.${new Date(now.getTime() - 86_400_000).toISOString()}`) + .limit(100); + let refreshed = 0; + let failed = 0; + for (const r of (data ?? []) as Array<{ origin: string }>) { + const out = await addOrRefreshProgram(r.origin, null); + out.ok ? refreshed++ : failed++; + } + return { refreshed, failed }; +} + +// ─── Joins ────────────────────────────────────────────────────────────────── + +export type Join = { + id: string; + ownerId: string; + origin: string; + programId: string; + membershipRef: string | null; + code: string | null; + link: string | null; + ledgerUrl: string | null; + status: "active" | "pending" | "refused" | "ended"; + terms: unknown; + ledger: Record | null; + events: unknown[]; + syncedAt: string | null; + error: string | null; + createdAt: string; + hasToken: boolean; +}; + +const JOIN_COLUMNS = "id, owner_id, origin, program_id, membership_ref, code, link, ledger_url, status, terms, ledger, events, synced_at, error, created_at, token"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function toJoin(r: any): Join { + return { + id: r.id, + ownerId: r.owner_id, + origin: r.origin, + programId: r.program_id, + membershipRef: r.membership_ref ?? null, + code: r.code ?? null, + link: r.link ?? null, + ledgerUrl: r.ledger_url ?? null, + status: r.status, + terms: r.terms ?? null, + ledger: r.ledger ?? null, + events: Array.isArray(r.events) ? r.events : [], + syncedAt: r.synced_at ?? null, + error: r.error ?? null, + createdAt: r.created_at, + hasToken: !!r.token, + }; +} + +export async function listJoins(ownerId: string): Promise { + const { data } = await serviceClient() + .from("affiliate_joins") + .select(JOIN_COLUMNS) + .eq("owner_id", ownerId) + .order("created_at", { ascending: false }) + .limit(200); + return (data ?? []).map(toJoin); +} + +export async function joinById(id: string, ownerId: string): Promise { + const { data } = await serviceClient().from("affiliate_joins").select(JOIN_COLUMNS).eq("id", id).eq("owner_id", ownerId).maybeSingle(); + return data ? toJoin(data) : null; +} + +export type JoinExternalOutcome = { ok: true; join: Join; existing: boolean } | { ok: false; error: string }; + +/** + * Join another merchant's program on a user's behalf: with the user's own + * profile, pay address and a webhook that lands back here. + */ +export async function joinExternal( + user: { id: string; email?: string | null; displayName?: string | null }, + input: { origin: string; programId?: string; code?: string }, +): Promise { + const svc = serviceClient(); + let row = await directoryRow(input.origin); + if (!row?.descriptor) { + const added = await addOrRefreshProgram(input.origin, user.id); + if (!added.ok) return { ok: false, error: added.error }; + row = added.row; + } + if (!row.descriptor) return { ok: false, error: "That merchant's descriptor could not be read." }; + const program = input.programId + ? row.descriptor.programs.find((p) => p.id === input.programId) + : row.descriptor.programs.find((p) => p.status === "active") ?? row.descriptor.programs[0]; + if (!program) return { ok: false, error: `No program ${input.programId ?? ""} at ${row.origin}.`.replace(/\s+\./, ".") }; + + const { data: twin } = await svc + .from("affiliate_joins") + .select(JOIN_COLUMNS) + .eq("owner_id", user.id) + .ilike("origin", row.origin) + .eq("program_id", program.id) + .maybeSingle(); + if (twin && twin.status !== "refused" && twin.status !== "ended") return { ok: true, join: toJoin(twin), existing: true }; + + const mine = await ensureMembershipForUser(user); + if (!mine) return { ok: false, error: "Could not create your CrawlProof affiliate profile." }; + + const { data: inserted, error: insErr } = await svc + .from("affiliate_joins") + .upsert( + { owner_id: user.id, origin: row.origin, program_id: program.id, status: "pending", terms: program.pays, updated_at: new Date().toISOString() }, + { onConflict: "owner_id,origin,program_id", ignoreDuplicates: false }, + ) + .select(JOIN_COLUMNS) + .single(); + if (insErr || !inserted) return { ok: false, error: insErr?.message ?? "Could not start the join." }; + + const site = env.siteUrl.replace(/\/$/, ""); + const answer = await joinProgram(program, { + profile: profileUrlForMembership(mine), + pay: mine.payAddress ?? undefined, + webhook: `${site}/api/affiliate/v1/events/${inserted.id}`, + code: input.code ?? mine.code, + }); + const now = new Date().toISOString(); + if (!answer.ok) { + await svc.from("affiliate_joins").update({ error: answer.error, updated_at: now }).eq("id", inserted.id); + return { ok: false, error: answer.error }; + } + const a = answer.answer; + const { data: updated } = await svc + .from("affiliate_joins") + .update({ + membership_ref: a.membership, + code: a.code ?? null, + link: a.link ?? null, + token: a.token ?? null, + ledger_url: a.ledger ?? null, + status: a.status, + terms: a.pays ?? program.pays, + error: null, + updated_at: now, + }) + .eq("id", inserted.id) + .select(JOIN_COLUMNS) + .single(); + const join = toJoin(updated ?? inserted); + if (join.ledgerUrl && join.hasToken) await syncJoin(join.id, user.id); + return { ok: true, join: (await joinById(join.id, user.id)) ?? join, existing: false }; +} + +/** Read the merchant's ledger for one join and keep it. */ +export async function syncJoin(joinId: string, ownerId: string): Promise<{ ok: true; join: Join } | { ok: false; error: string }> { + const svc = serviceClient(); + const { data } = await svc.from("affiliate_joins").select(JOIN_COLUMNS).eq("id", joinId).eq("owner_id", ownerId).maybeSingle(); + if (!data) return { ok: false, error: "No such join." }; + if (!data.ledger_url || !data.token) return { ok: false, error: "This program gave no ledger to read." }; + const read = await readLedger(data.ledger_url, data.token); + const now = new Date().toISOString(); + if (!read.ok) { + const patch: Record = { error: read.error, updated_at: now }; + if (read.status === 401 || read.status === 403) patch.status = "ended"; + await svc.from("affiliate_joins").update(patch).eq("id", joinId); + return { ok: false, error: read.error }; + } + const status = typeof read.ledger.status === "string" && ["active", "pending", "refused", "ended"].includes(read.ledger.status) ? read.ledger.status : "active"; + await svc.from("affiliate_joins").update({ ledger: read.ledger, synced_at: now, status, error: null, updated_at: now }).eq("id", joinId); + const join = await joinById(joinId, ownerId); + return join ? { ok: true, join } : { ok: false, error: "Lost the join." }; +} + +/** An inbound webhook from a merchant we joined: keep the last 50 and re-sync. */ +export async function recordInboundEvent(joinId: string, payload: unknown): Promise { + const svc = serviceClient(); + const { data } = await svc.from("affiliate_joins").select("id, owner_id, events").eq("id", joinId).maybeSingle(); + if (!data) return false; + const events = Array.isArray(data.events) ? data.events : []; + const next = [{ received: new Date().toISOString(), payload }, ...events].slice(0, 50); + await svc.from("affiliate_joins").update({ events: next, updated_at: new Date().toISOString() }).eq("id", joinId); + void syncJoin(joinId, data.owner_id).catch(() => {}); + return true; +} + +/** Re-read every join's ledger not synced in the last day. */ +export async function syncStaleJoins(svc: Svc, now = new Date()): Promise<{ synced: number; failed: number }> { + const { data } = await svc + .from("affiliate_joins") + .select("id, owner_id") + .not("token", "is", null) + .not("ledger_url", "is", null) + .in("status", ["active", "pending"]) + .or(`synced_at.is.null,synced_at.lt.${new Date(now.getTime() - 86_400_000).toISOString()}`) + .limit(200); + let synced = 0; + let failed = 0; + for (const j of (data ?? []) as Array<{ id: string; owner_id: string }>) { + const out = await syncJoin(j.id, j.owner_id); + out.ok ? synced++ : failed++; + } + return { synced, failed }; +} diff --git a/lib/affiliate/memberships.ts b/lib/affiliate/memberships.ts new file mode 100644 index 00000000..314d9579 --- /dev/null +++ b/lib/affiliate/memberships.ts @@ -0,0 +1,341 @@ +// Memberships in the program we run: join, look up, and the ledger an +// affiliate reads. Every write uses the service client and every read is +// scoped by the caller's own membership, because the route is public. + +import crypto from "node:crypto"; +import { serviceClient } from "../supabase/service"; +import { env } from "../env"; +import { hashAffiliateToken, mintAffiliateToken } from "./tokens"; +import { fetchProfile } from "./client"; +import { PAYS, PROGRAM_ID, WINDOW_DAYS, CURRENCY } from "./program"; +import { codeFromProfile, isAffiliateCode, slugify, type JoinRequest, type Pays } from "./spec"; + +export type Membership = { + id: string; + program: string; + ownerId: string | null; + profileUrl: string | null; + kind: "person" | "agent" | "organization"; + displayName: string | null; + email: string | null; + payAddress: string | null; + webhookUrl: string | null; + code: string; + tokenPrefix: string; + status: "active" | "pending" | "refused" | "ended"; + terms: Pays[]; + createdAt: string; +}; + +const COLUMNS = + "id, program, owner_id, profile_url, kind, display_name, email, pay_address, webhook_url, code, token_prefix, status, terms, created_at"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function toMembership(r: any): Membership { + return { + id: r.id, + program: r.program, + ownerId: r.owner_id ?? null, + profileUrl: r.profile_url ?? null, + kind: r.kind ?? "person", + displayName: r.display_name ?? null, + email: r.email ?? null, + payAddress: r.pay_address ?? null, + webhookUrl: r.webhook_url ?? null, + code: r.code, + tokenPrefix: r.token_prefix, + status: r.status, + terms: Array.isArray(r.terms) ? (r.terms as Pays[]) : PAYS, + createdAt: r.created_at, + }; +} + +export function linkForMembership(m: Pick): string { + return `${env.siteUrl.replace(/\/$/, "")}/?oa=${encodeURIComponent(m.code)}`; +} + +export function profileUrlForMembership(m: Pick): string { + return `${env.siteUrl.replace(/\/$/, "")}/affiliate/u/${encodeURIComponent(m.code)}/openprofile.md`; +} + +export async function membershipById(id: string): Promise { + const { data } = await serviceClient().from("affiliate_memberships").select(COLUMNS).eq("id", id).maybeSingle(); + return data ? toMembership(data) : null; +} + +export async function membershipByCode(code: string): Promise { + if (!isAffiliateCode(code)) return null; + const { data } = await serviceClient() + .from("affiliate_memberships") + .select(COLUMNS) + .eq("program", PROGRAM_ID) + .ilike("code", code) + .maybeSingle(); + return data ? toMembership(data) : null; +} + +export async function membershipByToken(plaintext: string): Promise { + const { data } = await serviceClient() + .from("affiliate_memberships") + .select(COLUMNS) + .eq("token_hash", hashAffiliateToken(plaintext)) + .maybeSingle(); + return data ? toMembership(data) : null; +} + +export async function membershipForUser(userId: string): Promise { + const { data } = await serviceClient() + .from("affiliate_memberships") + .select(COLUMNS) + .eq("program", PROGRAM_ID) + .eq("owner_id", userId) + .maybeSingle(); + return data ? toMembership(data) : null; +} + +/** A code nobody else has: the wanted one, else it with a short suffix. */ +async function freeCode(wanted: string): Promise { + const base = isAffiliateCode(wanted) ? wanted : "partner"; + const svc = serviceClient(); + for (let i = 0; i < 6; i++) { + const candidate = i === 0 ? base : `${base.slice(0, 26)}-${crypto.randomBytes(2).toString("hex")}`; + const { data } = await svc + .from("affiliate_memberships") + .select("id") + .eq("program", PROGRAM_ID) + .ilike("code", candidate) + .maybeSingle(); + if (!data) return candidate; + } + return `p-${crypto.randomBytes(6).toString("hex")}`; +} + +export type JoinOutcome = + | { ok: true; membership: Membership; token: string | null; existing: boolean } + | { ok: false; status: number; error: string }; + +/** + * Join our program. A second join by the same profile (or the same user) + * answers the existing membership without a new token (spec, "Joining"). + * ownerId links a CrawlProof user to the membership; outside affiliates have + * only a profile. + */ +export async function joinOurProgram( + request: Partial & { profile?: string }, + opts: { ownerId?: string | null; email?: string | null; displayName?: string | null } = {}, +): Promise { + const svc = serviceClient(); + const ownerId = opts.ownerId ?? null; + + if (ownerId) { + const mine = await membershipForUser(ownerId); + if (mine) return { ok: true, membership: mine, token: null, existing: true }; + } + + let profileUrl: string | null = request.profile ?? null; + let kind: Membership["kind"] = "person"; + let displayName = opts.displayName ?? null; + let email = opts.email ?? null; + let pay = request.pay ?? null; + let wantedCode = request.code ?? ""; + + if (profileUrl) { + const { data: twin } = await svc + .from("affiliate_memberships") + .select(COLUMNS) + .eq("program", PROGRAM_ID) + .ilike("profile_url", profileUrl) + .maybeSingle(); + if (twin) return { ok: true, membership: toMembership(twin), token: null, existing: true }; + + const profile = await fetchProfile(profileUrl); + if (!profile.ok) { + return { ok: false, status: 422, error: `Could not read the profile at ${profileUrl}: ${profile.error}` }; + } + kind = profile.facts.kind; + displayName = displayName ?? profile.facts.name ?? null; + email = email ?? profile.facts.email ?? null; + pay = pay ?? profile.facts.pay ?? null; + if (!wantedCode) wantedCode = codeFromProfile(profileUrl, profile.facts); + } else if (!ownerId) { + return { ok: false, status: 400, error: "profile is required." }; + } + + if (!wantedCode) { + const local = (email ?? "").split("@")[0] ?? ""; + wantedCode = slugify(displayName ?? local); + } + const code = await freeCode(wantedCode); + const minted = mintAffiliateToken(); + + const { data, error } = await svc + .from("affiliate_memberships") + .insert({ + program: PROGRAM_ID, + owner_id: ownerId, + profile_url: profileUrl, + kind, + display_name: displayName, + email, + pay_address: pay, + webhook_url: request.webhook ?? null, + code, + token_prefix: minted.prefix, + token_hash: minted.hash, + status: "active", + terms: PAYS, + }) + .select(COLUMNS) + .single(); + if (error || !data) { + if (/duplicate key|unique/i.test(error?.message ?? "")) { + // A race on the same profile or owner: answer the row that won. + if (ownerId) { + const mine = await membershipForUser(ownerId); + if (mine) return { ok: true, membership: mine, token: null, existing: true }; + } + return { ok: false, status: 409, error: "That code was taken a moment ago. Try again." }; + } + return { ok: false, status: 500, error: error?.message ?? "Could not create the membership." }; + } + return { ok: true, membership: toMembership(data), token: minted.plaintext, existing: false }; +} + +/** Find-or-create the membership for a CrawlProof user. */ +export async function ensureMembershipForUser(user: { + id: string; + email?: string | null; + displayName?: string | null; +}): Promise { + const found = await membershipForUser(user.id); + if (found) return found; + const joined = await joinOurProgram({}, { ownerId: user.id, email: user.email ?? null, displayName: user.displayName ?? null }); + return joined.ok ? joined.membership : null; +} + +export async function setPayAddress(membershipId: string, address: string | null): Promise<{ ok: true } | { ok: false; error: string }> { + const { error } = await serviceClient() + .from("affiliate_memberships") + .update({ pay_address: address, updated_at: new Date().toISOString() }) + .eq("id", membershipId); + return error ? { ok: false, error: error.message } : { ok: true }; +} + +export async function setWebhook(membershipId: string, webhook: string | null): Promise { + await serviceClient() + .from("affiliate_memberships") + .update({ webhook_url: webhook, updated_at: new Date().toISOString() }) + .eq("id", membershipId); +} + +/** Rotate the token: the old one stops working the moment this returns. */ +export async function rotateToken(membershipId: string): Promise { + const minted = mintAffiliateToken(); + const { error } = await serviceClient() + .from("affiliate_memberships") + .update({ token_prefix: minted.prefix, token_hash: minted.hash, updated_at: new Date().toISOString() }) + .eq("id", membershipId); + if (error) throw new Error(error.message); + return minted.plaintext; +} + +// ─── The ledger ───────────────────────────────────────────────────────────── + +export type LedgerConversion = { + id: string; + at: string; + event: string; + order: string; + amount: number; + commission: number; + status: string; + held_until?: string; + reason?: string; + recurring?: { n: number; of: number | null }; + updated: string; +}; + +export type Ledger = { + membership: string; + program: string; + currency: string; + code: string; + link: string; + status: string; + pays: Pays[]; + clicks: { total: number; window: number }; + balance: { pending: number; approved: number; paid: number }; + conversions: LedgerConversion[]; + payouts: Array<{ id: string; at: string; amount: number; method: string; status: string; tx: string | null }>; +}; + +const dollars = (cents: number) => Math.round(cents) / 100; + +export async function ledgerFor(m: Membership, since?: Date | null): Promise { + const svc = serviceClient(); + const windowStart = new Date(Date.now() - WINDOW_DAYS * 86_400_000).toISOString(); + + const [clicksAll, clicksWindow, convRows, sums, payoutRows] = await Promise.all([ + svc.from("affiliate_clicks").select("id", { count: "exact", head: true }).eq("membership_id", m.id), + svc.from("affiliate_clicks").select("id", { count: "exact", head: true }).eq("membership_id", m.id).gte("at", windowStart), + (() => { + let q = svc + .from("affiliate_conversions") + .select("id, created_at, updated_at, event, order_ref, amount_cents, commission_cents, status, held_until, reason, recurring_n, recurring_of") + .eq("membership_id", m.id) + .order("created_at", { ascending: false }) + .limit(500); + if (since) q = q.or(`created_at.gte.${since.toISOString()},updated_at.gte.${since.toISOString()}`); + return q; + })(), + svc.from("affiliate_conversions").select("status, commission_cents").eq("membership_id", m.id), + svc + .from("affiliate_payouts") + .select("id, created_at, settled_at, amount_cents, method, status, tx_hash") + .eq("membership_id", m.id) + .order("created_at", { ascending: false }) + .limit(100), + ]); + + const balance = { pending: 0, approved: 0, paid: 0 }; + for (const r of (sums.data ?? []) as Array<{ status: string; commission_cents: number }>) { + if (r.status === "pending") balance.pending += r.commission_cents; + else if (r.status === "approved") balance.approved += r.commission_cents; + else if (r.status === "paid") balance.paid += r.commission_cents; + } + + return { + membership: m.id, + program: m.program, + currency: CURRENCY, + code: m.code, + link: linkForMembership(m), + status: m.status, + pays: m.terms, + clicks: { total: clicksAll.count ?? 0, window: clicksWindow.count ?? 0 }, + balance: { pending: dollars(balance.pending), approved: dollars(balance.approved), paid: dollars(balance.paid) }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + conversions: ((convRows.data ?? []) as any[]).map((r) => ({ + id: r.id, + at: r.created_at, + event: r.event, + order: r.order_ref, + amount: dollars(r.amount_cents), + commission: dollars(r.commission_cents), + status: r.status, + ...(r.held_until ? { held_until: r.held_until } : {}), + ...(r.reason ? { reason: r.reason } : {}), + ...(r.recurring_n ? { recurring: { n: r.recurring_n, of: r.recurring_of ?? null } } : {}), + updated: r.updated_at, + })), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payouts: ((payoutRows.data ?? []) as any[]).map((r) => ({ + id: r.id, + at: r.settled_at ?? r.created_at, + amount: dollars(r.amount_cents), + method: r.method, + status: r.status, + tx: r.tx_hash ?? null, + })), + }; +} diff --git a/lib/affiliate/payouts.ts b/lib/affiliate/payouts.ts new file mode 100644 index 00000000..2ac8ee02 --- /dev/null +++ b/lib/affiliate/payouts.ts @@ -0,0 +1,98 @@ +// Paying an affiliate: approved conversions → one payout row → CoinPay. The +// RPC debits first (conversions flip to paid), CoinPay sends second, and a +// failed send puts them back, so money is delayed by an outage but never +// sent twice. + +import { serviceClient } from "../supabase/service"; +import { createCryptoPayout } from "../coinpay"; +import { PAYOUT_COINPAY_CURRENCY, PAYOUT_METHOD, PAYOUT_MIN_CENTS } from "./program"; +import { walletOf, isPayAddress } from "./spec"; +import { membershipById, type Membership } from "./memberships"; +import { queueEvent } from "./webhooks"; + +type Svc = ReturnType; + +export type PayoutOutcome = + | { ok: true; payoutId: string; amountCents: number; txHash: string | null; status: string } + | { ok: false; error: string }; + +export async function requestPayout(membership: Membership, opts: { minCents?: number } = {}): Promise { + if (membership.status !== "active") return { ok: false, error: "This membership is not active." }; + if (!membership.payAddress || !isPayAddress(membership.payAddress)) { + return { ok: false, error: "Set a payout address first: an EVM address, paid in USDC on Polygon." }; + } + const svc = serviceClient(); + const { data, error } = await svc.rpc("affiliate_request_payout", { + p_membership: membership.id, + p_method: PAYOUT_METHOD, + p_address: membership.payAddress, + p_min_cents: opts.minCents ?? PAYOUT_MIN_CENTS, + }); + if (error) return { ok: false, error: error.message.replace(/^affiliate_request_payout:\s*/, "") }; + const row = (Array.isArray(data) ? data[0] : data) as { payout_id: string; amount_cents: number } | undefined; + if (!row?.payout_id) return { ok: false, error: "Nothing approved to pay." }; + + const sent = await createCryptoPayout({ + recipientEmail: membership.email ?? `affiliate+${membership.code}@crawlproof.com`, + recipientWallet: walletOf(membership.payAddress), + amountUsd: row.amount_cents / 100, + currency: PAYOUT_COINPAY_CURRENCY, + }); + if (!sent.ok) { + await svc.rpc("affiliate_fail_payout", { p_payout: row.payout_id, p_error: sent.error }); + return { ok: false, error: sent.error }; + } + await svc + .from("affiliate_payouts") + .update({ status: "sent", coinpay_payout_id: sent.payoutId, tx_hash: sent.txHash, settled_at: new Date().toISOString() }) + .eq("id", row.payout_id); + await queueEvent(membership, "payout.sent", { + payout: { id: row.payout_id, amount: row.amount_cents / 100, method: PAYOUT_METHOD, tx: sent.txHash }, + }); + return { ok: true, payoutId: row.payout_id, amountCents: row.amount_cents, txHash: sent.txHash, status: sent.status }; +} + +const WEEK_MS = 7 * 86_400_000; + +/** + * The weekly schedule: every active membership with an address and an + * approved balance at or over the minimum, whose last payout is a week old or + * older, is paid. Runs from the hourly cron, so "weekly" means at most once + * in any seven days rather than on a fixed weekday. + */ +export async function runScheduledPayouts(svc: Svc, now = new Date()): Promise<{ paid: number; failed: number; skipped: number }> { + const { data: sums } = await svc.from("affiliate_conversions").select("membership_id, commission_cents").eq("status", "approved"); + const byMembership = new Map(); + for (const r of (sums ?? []) as Array<{ membership_id: string; commission_cents: number }>) { + byMembership.set(r.membership_id, (byMembership.get(r.membership_id) ?? 0) + r.commission_cents); + } + let paid = 0; + let failed = 0; + let skipped = 0; + for (const [membershipId, cents] of byMembership) { + if (cents < PAYOUT_MIN_CENTS) { + skipped++; + continue; + } + const membership = await membershipById(membershipId); + if (!membership || membership.status !== "active" || !membership.payAddress) { + skipped++; + continue; + } + const { data: last } = await svc + .from("affiliate_payouts") + .select("created_at") + .eq("membership_id", membershipId) + .in("status", ["sent", "requested"]) + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); + if (last && now.getTime() - new Date(last.created_at).getTime() < WEEK_MS) { + skipped++; + continue; + } + const outcome = await requestPayout(membership); + outcome.ok ? paid++ : failed++; + } + return { paid, failed, skipped }; +} diff --git a/lib/affiliate/program.ts b/lib/affiliate/program.ts new file mode 100644 index 00000000..8426fcef --- /dev/null +++ b/lib/affiliate/program.ts @@ -0,0 +1,106 @@ +// The program CrawlProof runs, stated once so the descriptor, the join +// answer, the dashboard and the marketing page cannot disagree. Change a +// number here and every surface follows; the spec's program.changed event +// is queued by the cron when it notices `terms` on a membership differ. +// +// Why 30 percent and not the 60 percent the 2026-06 referral header promised: +// a credits pack is spent on ads whose clicks pay publishers 1.4c of every +// 2c, so 60 percent of the pack price would be paid twice over. Thirty +// percent of a first purchase sits inside the margin that is actually ours. + +import { WELL_KNOWN_PATH, type Descriptor, type Pays, type Program } from "./spec"; + +export const PROGRAM_ID = "partners"; +export const PROGRAM_TITLE = "CrawlProof partner program"; +export const PAYS: Pays[] = [{ event: "sale", kind: "percent", value: 30 }]; +export const WINDOW_DAYS = 30; +export const HOLD_DAYS = 30; +export const PAYOUT_METHOD = "usdc/eip155:137"; +export const PAYOUT_COINPAY_CURRENCY = "USDC_POL"; +export const PAYOUT_MIN_CENTS = 1000; +export const PAYOUT_SCHEDULE = "weekly" as const; +export const DISCLOSURE = "Paid partner link"; +export const CURRENCY = "USD"; + +/** The program as a parsed Program, for code that wants the typed shape. */ +export function ourProgram(siteUrl: string): Program { + const base = siteUrl.replace(/\/$/, ""); + return { + id: PROGRAM_ID, + title: PROGRAM_TITLE, + url: `${base}/affiliate`, + join: `${base}/api/affiliate/v1/join`, + ledger: `${base}/api/affiliate/v1/ledger`, + approval: "open", + pays: PAYS, + link: { param: "oa", template: `${base}/?oa={code}`, deep: true, aliases: [] }, + window: WINDOW_DAYS, + attribution: "last", + hold_days: HOLD_DAYS, + payout: { methods: [PAYOUT_METHOD], min: PAYOUT_MIN_CENTS / 100, schedule: PAYOUT_SCHEDULE }, + disclosure: DISCLOSURE, + self: "refused", + creatives: `${base}/affiliate/creatives.json`, + status: "active", + extra: {}, + }; +} + +/** The JSON we serve at /.well-known/openaffiliate.json. */ +export function ourDescriptorJson(siteUrl: string, updated: string): Record { + const base = siteUrl.replace(/\/$/, ""); + const p = ourProgram(siteUrl); + return { + merchant: { + name: "CrawlProof", + web: base, + operator: `${base}/.well-known/openprofile.md`, + currency: CURRENCY, + terms: `${base}/affiliate/terms`, + jwks: `${base}/.well-known/openaffiliate-jwks.json`, + }, + updated, + programs: [ + { + id: p.id, + title: p.title, + url: p.url, + join: p.join, + ledger: p.ledger, + approval: p.approval, + pays: p.pays, + link: { param: p.link.param, template: p.link.template, deep: p.link.deep }, + window: p.window, + attribution: p.attribution, + hold_days: p.hold_days, + payout: p.payout, + disclosure: p.disclosure, + self: p.self, + creatives: p.creatives, + status: p.status, + }, + ], + }; +} + +export function ourDescriptor(siteUrl: string, updated: string): Descriptor { + return { + merchant: { + name: "CrawlProof", + web: siteUrl.replace(/\/$/, ""), + currency: CURRENCY, + extra: {}, + }, + updated, + programs: [ourProgram(siteUrl)], + }; +} + +export const DESCRIPTOR_PATH = WELL_KNOWN_PATH; + +/** The one line of terms a person reads before joining. */ +export function termsLine(): string { + const sale = PAYS.find((p) => p.event === "sale"); + const pct = sale?.kind === "percent" ? `${sale.value}%` : sale ? `$${sale.value}` : "nothing"; + return `${pct} of every purchase for ${WINDOW_DAYS} days after the click, paid in USDC on Polygon once the ${HOLD_DAYS}-day refund window passes, from $${(PAYOUT_MIN_CENTS / 100).toFixed(0)}.`; +} diff --git a/lib/affiliate/spec.ts b/lib/affiliate/spec.ts new file mode 100644 index 00000000..7d940d87 --- /dev/null +++ b/lib/affiliate/spec.ts @@ -0,0 +1,439 @@ +// OpenAffiliate, the pure half: the descriptor a merchant serves, the join +// request an affiliate sends, and the arithmetic between them. No I/O, no +// env, no Supabase, so tests and the CLI can import it without a server. +// Spec: https://logicsrc.com/docs/openaffiliate + +export const WELL_KNOWN_PATH = "/.well-known/openaffiliate.json"; +export const DEFAULT_PARAM = "oa"; +export const TOKEN_PREFIX = "oa_"; + +export const PAY_EVENTS = ["sale", "subscription", "signup", "lead", "install", "other"] as const; +export type PayEvent = (typeof PAY_EVENTS)[number]; +export function isPayEvent(s: unknown): s is PayEvent { + return typeof s === "string" && (PAY_EVENTS as readonly string[]).includes(s); +} + +export type Pays = { + event: PayEvent; + kind: "percent" | "amount"; + value: number; + /** subscription only: how many renewals pay; absent is every one */ + months?: number; +}; + +export type ProgramLink = { + param: string; + template?: string; + deep: boolean; + aliases: string[]; +}; + +export type Payout = { + methods: string[]; + min?: number; + schedule?: "weekly" | "monthly" | "on_request"; +}; + +export type Program = { + id: string; + title: string; + url?: string; + join?: string; + ledger?: string; + approval: "open" | "review"; + pays: Pays[]; + link: ProgramLink; + window?: number; + attribution: "last" | "first"; + hold_days?: number; + payout?: Payout; + disclosure?: string; + self: "refused" | "allowed"; + regions?: string[]; + creatives?: string; + status: "active" | "paused" | "closed"; + updated?: string; + /** keys this module does not name, kept under the merchant's own names */ + extra: Record; +}; + +export type Merchant = { + name: string; + web?: string; + operator?: string; + currency: string; + terms?: string; + jwks?: string; + extra: Record; +}; + +export type Descriptor = { + merchant: Merchant; + updated?: string; + programs: Program[]; +}; + +export type ParseResult = + | { ok: true; descriptor: Descriptor; warnings: string[] } + | { ok: false; error: string }; + +const MERCHANT_KEYS = new Set(["name", "web", "operator", "currency", "terms", "jwks"]); +const PROGRAM_KEYS = new Set([ + "id", "title", "url", "join", "ledger", "approval", "pays", "link", "window", "attribution", + "hold_days", "payout", "disclosure", "self", "regions", "creatives", "status", "updated", +]); + +function isObj(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} +function str(v: unknown): string | undefined { + return typeof v === "string" && v.trim() ? v.trim() : undefined; +} +function num(v: unknown): number | undefined { + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} +function httpUrl(v: unknown): string | undefined { + const s = str(v); + if (!s) return undefined; + try { + const u = new URL(s); + if (u.protocol !== "https:" && u.protocol !== "http:") return undefined; + return u.toString(); + } catch { + return undefined; + } +} + +/** A code is what goes in ?oa=. Lower-case, 3 to 32 chars, dashes inside. */ +export function isAffiliateCode(s: unknown): s is string { + return typeof s === "string" && /^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/.test(s); +} + +/** A slug from anything, good enough to be a code or fall back to. */ +export function slugify(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 32) + .replace(/-+$/g, ""); +} + +/** Derive a stable program id from its title when the merchant gave none. */ +export function programIdFrom(p: { id?: string; title: string }): string { + return p.id ?? slugify(p.title) ?? "default"; +} + +export function parsePays(v: unknown): { pays: Pays[]; warnings: string[] } { + const warnings: string[] = []; + const pays: Pays[] = []; + if (!Array.isArray(v)) return { pays, warnings: ["pays is not a list"] }; + for (const raw of v) { + if (!isObj(raw)) continue; + const event = raw.event; + if (!isPayEvent(event)) { + warnings.push(`pays entry with unknown event ${JSON.stringify(event)} dropped`); + continue; + } + const kind = raw.kind === "amount" ? "amount" : raw.kind === "percent" ? "percent" : null; + const value = num(raw.value); + if (!kind || value === undefined || value < 0) { + warnings.push(`pays entry for ${event} needs kind percent|amount and a value`); + continue; + } + if (kind === "percent" && value > 100) { + warnings.push(`pays entry for ${event} is over 100 percent`); + continue; + } + const months = num(raw.months); + pays.push({ + event, + kind, + value, + ...(months !== undefined && months > 0 ? { months: Math.floor(months) } : {}), + }); + } + return { pays, warnings }; +} + +export function parseProgram(raw: unknown, index: number): { program?: Program; warnings: string[] } { + const warnings: string[] = []; + if (!isObj(raw)) return { warnings: [`programs[${index}] is not an object`] }; + const title = str(raw.title); + if (!title) return { warnings: [`programs[${index}] has no title`] }; + const { pays, warnings: payWarnings } = parsePays(raw.pays); + warnings.push(...payWarnings.map((w) => `${title}: ${w}`)); + if (!pays.length) return { warnings: [...warnings, `${title}: pays nothing, dropped`] }; + + const linkRaw = isObj(raw.link) ? raw.link : {}; + const param = str(linkRaw.param) ?? DEFAULT_PARAM; + const aliases = Array.isArray(linkRaw.aliases) + ? linkRaw.aliases.filter((a): a is string => typeof a === "string" && a.length > 0) + : []; + const link: ProgramLink = { + param, + template: str(linkRaw.template), + deep: linkRaw.deep === true, + aliases, + }; + + let payout: Payout | undefined; + if (isObj(raw.payout)) { + const methods = Array.isArray(raw.payout.methods) + ? raw.payout.methods.filter((m): m is string => typeof m === "string" && m.length > 0) + : []; + const schedule = raw.payout.schedule; + payout = { + methods, + min: num(raw.payout.min), + schedule: + schedule === "weekly" || schedule === "monthly" || schedule === "on_request" + ? schedule + : undefined, + }; + } + + const extra: Record = {}; + for (const [k, v] of Object.entries(raw)) if (!PROGRAM_KEYS.has(k)) extra[k] = v; + + const status = raw.status; + const program: Program = { + id: str(raw.id) ?? programIdFrom({ title }), + title, + url: httpUrl(raw.url), + join: httpUrl(raw.join), + ledger: httpUrl(raw.ledger), + approval: raw.approval === "open" ? "open" : "review", + pays, + link, + window: num(raw.window), + attribution: raw.attribution === "first" ? "first" : "last", + hold_days: num(raw.hold_days), + payout, + disclosure: str(raw.disclosure), + self: raw.self === "allowed" ? "allowed" : "refused", + regions: Array.isArray(raw.regions) + ? raw.regions.filter((r): r is string => typeof r === "string") + : undefined, + creatives: httpUrl(raw.creatives), + status: status === "paused" || status === "closed" ? status : "active", + updated: str(raw.updated), + extra, + }; + return { program, warnings }; +} + +/** Parse a merchant's descriptor. Lenient by design: every rule degrades. */ +export function parseDescriptor(input: unknown): ParseResult { + if (!isObj(input)) return { ok: false, error: "The descriptor is not a JSON object." }; + if (!isObj(input.merchant)) return { ok: false, error: "The descriptor has no merchant." }; + const name = str(input.merchant.name); + if (!name) return { ok: false, error: "merchant.name is required." }; + + const mExtra: Record = {}; + for (const [k, v] of Object.entries(input.merchant)) if (!MERCHANT_KEYS.has(k)) mExtra[k] = v; + const merchant: Merchant = { + name, + web: httpUrl(input.merchant.web), + operator: httpUrl(input.merchant.operator), + currency: (str(input.merchant.currency) ?? "USD").toUpperCase(), + terms: httpUrl(input.merchant.terms), + jwks: httpUrl(input.merchant.jwks), + extra: mExtra, + }; + + if (!Array.isArray(input.programs) || !input.programs.length) { + return { ok: false, error: "The descriptor lists no programs." }; + } + const warnings: string[] = []; + const programs: Program[] = []; + const seen = new Set(); + input.programs.forEach((raw, i) => { + const { program, warnings: w } = parseProgram(raw, i); + warnings.push(...w); + if (!program) return; + if (seen.has(program.id)) { + warnings.push(`duplicate program id ${program.id} dropped`); + return; + } + seen.add(program.id); + programs.push(program); + }); + if (!programs.length) return { ok: false, error: "No program in the descriptor pays anything." }; + + return { ok: true, descriptor: { merchant, updated: str(input.updated), programs }, warnings }; +} + +/** The commission one conversion earns, in cents. 0 when the event is unpaid. */ +export function commissionCents( + pays: Pays[], + event: PayEvent, + amountCents: number, + renewalN?: number, +): number { + const entry = pays.find((p) => p.event === event); + if (!entry) return 0; + if (entry.event === "subscription" && entry.months && renewalN && renewalN > entry.months) return 0; + if (entry.kind === "amount") return Math.max(0, Math.round(entry.value * 100)); + return Math.max(0, Math.round((Math.max(0, amountCents) * entry.value) / 100)); +} + +/** The link an affiliate hands out: a deep URL when the program allows it, else the template, else web + param. */ +export function linkFor( + program: Pick, + code: string, + web?: string, + deepUrl?: string, +): string | null { + const param = program.link.param || DEFAULT_PARAM; + if (deepUrl && program.link.deep) { + try { + const u = new URL(deepUrl); + u.searchParams.set(param, code); + return u.toString(); + } catch { + /* fall through to the template */ + } + } + if (program.link.template) return program.link.template.replaceAll("{code}", encodeURIComponent(code)); + const base = web ?? program.url; + if (!base) return null; + try { + const u = new URL(base); + u.searchParams.set(param, code); + return u.toString(); + } catch { + return null; + } +} + +/** A descriptor is verified when it came from the merchant's own origin (spec, "Discovery"). */ +export function isVerifiedOrigin(fetchedFrom: string, merchantWeb?: string): boolean { + try { + const from = new URL(fetchedFrom); + if (from.pathname === WELL_KNOWN_PATH) return true; + if (!merchantWeb) return false; + return new URL(merchantWeb).origin === from.origin; + } catch { + return false; + } +} + +// ─── Join ─────────────────────────────────────────────────────────────────── + +export type JoinRequest = { + program?: string; + profile: string; + pay?: string; + webhook?: string; + code?: string; +}; + +export type JoinParse = { ok: true; request: JoinRequest } | { ok: false; error: string }; + +/** CAIP-10 (`eip155:137:0x…`) or a bare EVM address. Anything else is refused. */ +export function isPayAddress(s: unknown): s is string { + if (typeof s !== "string") return false; + const bare = s.includes(":") ? s.split(":").pop() ?? "" : s; + return /^0x[0-9a-fA-F]{40}$/.test(bare); +} + +/** The wallet part of a pay address, for the payout rail. */ +export function walletOf(pay: string): string { + return pay.includes(":") ? (pay.split(":").pop() ?? pay) : pay; +} + +export function parseJoinRequest(body: unknown): JoinParse { + if (!isObj(body)) return { ok: false, error: "Send a JSON object." }; + const profile = httpUrl(body.profile); + if (!profile) return { ok: false, error: "profile is required and must be an https URL to an OpenProfile.md." }; + const request: JoinRequest = { profile }; + const program = str(body.program); + if (program) request.program = program; + if (body.pay !== undefined && body.pay !== null && body.pay !== "") { + if (!isPayAddress(body.pay)) return { ok: false, error: "pay must be an EVM address or a CAIP-10 account (eip155:137:0x…)." }; + request.pay = body.pay; + } + if (body.webhook !== undefined && body.webhook !== null && body.webhook !== "") { + const webhook = httpUrl(body.webhook); + if (!webhook || !webhook.startsWith("https://")) return { ok: false, error: "webhook must be an https URL." }; + request.webhook = webhook; + } + if (body.code !== undefined && body.code !== null && body.code !== "") { + if (!isAffiliateCode(body.code)) return { ok: false, error: "code must be 3 to 32 lower-case letters, digits or dashes." }; + request.code = body.code; + } + return { ok: true, request }; +} + +// ─── OpenProfile.md, the little we read of it ──────────────────────────────── + +export type ProfileFacts = { + name?: string; + kind: "person" | "agent" | "organization"; + handle?: string; + email?: string; + pay?: string; + web?: string; + operator?: string; + /** every URL in the Accounts section, for the rel=me style link-back check */ + accounts: string[]; +}; + +/** Read the identity block and the Accounts list of an OpenProfile.md. */ +export function readProfile(markdown: string): ProfileFacts { + const facts: ProfileFacts = { kind: "person", accounts: [] }; + const lines = markdown.split(/\r?\n/); + let section = ""; + for (const line of lines) { + const h1 = line.match(/^#\s+(.+?)\s*$/); + if (h1 && !facts.name) { + facts.name = h1[1]; + continue; + } + const h2 = line.match(/^##\s+(.+?)\s*$/); + if (h2) { + section = h2[1].trim().toLowerCase(); + continue; + } + const kv = line.match(/^(?:[-*]\s*)?\*{0,2}([A-Za-z][A-Za-z ]{0,20})\*{0,2}\s*:\s*(.+?)\s*$/); + if (kv && !section) { + const key = kv[1].trim().toLowerCase(); + const value = kv[2].trim(); + if (key === "kind") { + facts.kind = value.toLowerCase() === "agent" ? "agent" : value.toLowerCase() === "organization" ? "organization" : "person"; + } else if (key === "handle") facts.handle = value.replace(/^@/, ""); + else if (key === "email") facts.email = value.replace(/^<|>$/g, ""); + else if (key === "pay") facts.pay = value; + else if (key === "web") facts.web = value; + continue; + } + if (section === "accounts") { + const urls = line.match(/https?:\/\/[^\s)>\]]+/g); + if (urls) facts.accounts.push(...urls); + } + if (section === "operator") { + const url = line.match(/https?:\/\/[^\s)>\]]+/); + if (url && !facts.operator) facts.operator = url[0]; + } + } + return facts; +} + +/** A code suggestion from a profile: the handle, else the name, else the host. */ +export function codeFromProfile(profileUrl: string, facts?: Pick): string { + const fromHandle = facts?.handle ? slugify(facts.handle) : ""; + if (isAffiliateCode(fromHandle)) return fromHandle; + const fromName = facts?.name ? slugify(facts.name) : ""; + if (isAffiliateCode(fromName)) return fromName; + try { + const host = new URL(profileUrl).hostname.replace(/^www\./, ""); + const label = slugify(host.split(".")[0] ?? host); + if (isAffiliateCode(label)) return label; + const whole = slugify(host); + if (isAffiliateCode(whole)) return whole; + } catch { + /* fall through */ + } + return "partner"; +} diff --git a/lib/affiliate/tokens.ts b/lib/affiliate/tokens.ts new file mode 100644 index 00000000..c0c527ef --- /dev/null +++ b/lib/affiliate/tokens.ts @@ -0,0 +1,24 @@ +// oa_ tokens: the bearer credential a membership reads its ledger with. +// Same storage rule as API tokens (lib/sp/apiToken.ts): never the plaintext, +// only sha256(plaintext || SP_TOKEN_PEPPER) and an 8-char prefix for the UI. + +import crypto from "node:crypto"; +import { env } from "../env"; +import { TOKEN_PREFIX } from "./spec"; + +export type MintedAffiliateToken = { plaintext: string; prefix: string; hash: string }; + +export function mintAffiliateToken(): MintedAffiliateToken { + if (!env.spTokenPepper) throw new Error("SP_TOKEN_PEPPER not set."); + const plaintext = `${TOKEN_PREFIX}${crypto.randomBytes(32).toString("base64url")}`; + return { plaintext, prefix: plaintext.slice(0, 8), hash: hashAffiliateToken(plaintext) }; +} + +export function hashAffiliateToken(plaintext: string): string { + if (!env.spTokenPepper) throw new Error("SP_TOKEN_PEPPER not set."); + return crypto.createHash("sha256").update(plaintext + env.spTokenPepper, "utf8").digest("hex"); +} + +export function isAffiliateTokenShape(s: string | null | undefined): boolean { + return !!s && s.startsWith(TOKEN_PREFIX) && s.length >= 32 && s.length <= 128; +} diff --git a/lib/affiliate/webhooks.ts b/lib/affiliate/webhooks.ts new file mode 100644 index 00000000..cc36f5bc --- /dev/null +++ b/lib/affiliate/webhooks.ts @@ -0,0 +1,139 @@ +// Outbound webhooks to affiliates that gave a URL at join. Queued in +// affiliate_events, delivered by the cron with backoff, signed with the +// Ed25519 key in OPENAFFILIATE_SIGNING_KEY when it is set (spec, "Webhooks"). + +import crypto from "node:crypto"; +import { serviceClient } from "../supabase/service"; +import { env } from "../env"; +import type { Membership } from "./memberships"; + +type Svc = ReturnType; + +export const KEY_ID = "openaffiliate-2026-09"; + +// PKCS#8 DER prefix for an Ed25519 private key; the 32-byte seed follows. +const PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); + +function seed(): Buffer | null { + if (!env.openaffiliateSigningKey) return null; + const b = Buffer.from(env.openaffiliateSigningKey, "base64url"); + return b.length === 32 ? b : null; +} + +export function privateKey(): crypto.KeyObject | null { + const s = seed(); + if (!s) return null; + return crypto.createPrivateKey({ key: Buffer.concat([PKCS8_PREFIX, s]), format: "der", type: "pkcs8" }); +} + +/** The public half as a JWK, for /.well-known/openaffiliate-jwks.json. */ +export function publicJwk(): Record | null { + const priv = privateKey(); + if (!priv) return null; + const jwk = crypto.createPublicKey(priv).export({ format: "jwk" }) as Record; + return { ...jwk, kid: KEY_ID, use: "sig", alg: "EdDSA" }; +} + +export function signBody(body: string): string | null { + const priv = privateKey(); + if (!priv) return null; + return crypto.sign(null, Buffer.from(body, "utf8"), priv).toString("base64url"); +} + +/** Verify a signature made by signBody against a JWK (the affiliate side). */ +export function verifySignature(body: string, signatureHeader: string | null, jwk: Record): boolean { + if (!signatureHeader) return false; + const m = signatureHeader.match(/ed25519=([A-Za-z0-9_-]+)/); + if (!m) return false; + try { + const key = crypto.createPublicKey({ key: jwk as crypto.JsonWebKey, format: "jwk" }); + return crypto.verify(null, Buffer.from(body, "utf8"), key, Buffer.from(m[1], "base64url")); + } catch { + return false; + } +} + +export type AffiliateEventName = + | "membership.approved" + | "membership.refused" + | "membership.ended" + | "conversion.recorded" + | "conversion.approved" + | "conversion.reversed" + | "payout.sent" + | "program.changed"; + +/** Queue an event for a membership. No webhook URL means nothing is queued. */ +export async function queueEvent(membership: Membership, event: AffiliateEventName, body: Record): Promise { + if (!membership.webhookUrl) return; + const payload = { + event, + at: new Date().toISOString(), + merchant: env.siteUrl.replace(/\/$/, ""), + program: membership.program, + membership: membership.id, + ...body, + }; + await serviceClient().from("affiliate_events").insert({ membership_id: membership.id, event, payload }); +} + +const MAX_ATTEMPTS = 10; +const MAX_AGE_MS = 26 * 3_600_000; + +/** Deliver what is due. Backoff doubles from five minutes and gives up after a day. */ +export async function deliverDue(svc: Svc, now = new Date()): Promise<{ delivered: number; failed: number }> { + const { data: due } = await svc + .from("affiliate_events") + .select("id, membership_id, payload, attempts, created_at") + .is("delivered_at", null) + .lte("next_attempt_at", now.toISOString()) + .lt("attempts", MAX_ATTEMPTS) + .gte("created_at", new Date(now.getTime() - MAX_AGE_MS).toISOString()) + .order("created_at", { ascending: true }) + .limit(200); + let delivered = 0; + let failed = 0; + const urls = new Map(); + for (const e of (due ?? []) as Array<{ id: string; membership_id: string; payload: unknown; attempts: number }>) { + let url = urls.get(e.membership_id); + if (url === undefined) { + const { data: m } = await svc.from("affiliate_memberships").select("webhook_url").eq("id", e.membership_id).maybeSingle(); + const resolved: string | null = m?.webhook_url ?? null; + url = resolved; + urls.set(e.membership_id, resolved); + } + if (!url) { + await svc.from("affiliate_events").update({ delivered_at: now.toISOString(), last_error: "no webhook url" }).eq("id", e.id); + continue; + } + const body = JSON.stringify(e.payload); + const headers: Record = { "content-type": "application/json", "user-agent": "openaffiliate-merchant (crawlproof.com)" }; + const sig = signBody(body); + if (sig) { + headers["x-openaffiliate-signature"] = `ed25519=${sig}`; + headers["x-openaffiliate-key"] = KEY_ID; + } + let ok = false; + let error = ""; + try { + const res = await fetch(url, { method: "POST", headers, body, signal: AbortSignal.timeout(10_000), redirect: "manual" }); + ok = res.ok; + if (!ok) error = `HTTP ${res.status}`; + } catch (err) { + error = err instanceof Error ? err.message : String(err); + } + if (ok) { + delivered++; + await svc.from("affiliate_events").update({ delivered_at: now.toISOString(), attempts: e.attempts + 1, last_error: null }).eq("id", e.id); + } else { + failed++; + const attempts = e.attempts + 1; + const delayMs = Math.min(6 * 3_600_000, 5 * 60_000 * 2 ** attempts); + await svc + .from("affiliate_events") + .update({ attempts, next_attempt_at: new Date(now.getTime() + delayMs).toISOString(), last_error: error.slice(0, 300) }) + .eq("id", e.id); + } + } + return { delivered, failed }; +} diff --git a/lib/credits-finalize.ts b/lib/credits-finalize.ts index e63d38f3..6ba6f49f 100644 --- a/lib/credits-finalize.ts +++ b/lib/credits-finalize.ts @@ -3,6 +3,7 @@ import { getPaymentStatus } from "./coinpay"; import { sendPurchaseReceiptEmail } from "./email"; import { env } from "./env"; import { findPack } from "./credits"; +import { recordPurchaseConversion } from "./affiliate/attribution"; // CoinPay status / event vocabulary that maps to our local state machine. // Both the webhook and the polling endpoint reference these sets so a single @@ -65,6 +66,10 @@ export async function completePurchase(input: { throw new Error(error.message); } + // OpenAffiliate: if the buyer arrived through an affiliate link, record the + // conversion (idempotent on the purchase id; never fails the purchase). + await recordPurchaseConversion(svc, paymentId); + // Deposit-match promo: first deposit gets bonus ad credits (idempotent RPC). try { const { data: bonus } = await svc.rpc("ad_apply_deposit_bonus", { diff --git a/lib/env.ts b/lib/env.ts index df05e89d..51818999 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -181,6 +181,12 @@ export const env = { // alone cannot exploit any token without this server-side value. // Generate with `openssl rand -base64 32`. spTokenPepper: process.env.SP_TOKEN_PEPPER ?? "", + // OpenAffiliate webhook signing key: 32 random bytes, base64url, the seed + // of an Ed25519 key. The public half is served at + // /.well-known/openaffiliate-jwks.json. Unset means webhooks go unsigned, + // which the spec allows (the affiliate confirms against the ledger). + // Generate with `openssl rand -base64 32 | tr '+/' '-_' | tr -d '='`. + openaffiliateSigningKey: process.env.OPENAFFILIATE_SIGNING_KEY ?? "", // GitHub App — for connecting customer repos and opening automated PRs // (stats.js install, applying audit fixes). Register at // https://github.com/settings/apps with: diff --git a/lib/mcp/affiliate.ts b/lib/mcp/affiliate.ts new file mode 100644 index 00000000..61728c8a --- /dev/null +++ b/lib/mcp/affiliate.ts @@ -0,0 +1,160 @@ +// OpenAffiliate tools for the CrawlProof MCP server: an agent's own link and +// ledger in our program, the directory of other merchants' programs, and +// joining one. Scoped to the authenticated user; the route uses the service +// client, so every lookup goes through the user's membership. + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { serviceClient } from "@/lib/supabase/service"; +import { ensureMembershipForUser, ledgerFor, profileUrlForMembership, setPayAddress } from "@/lib/affiliate/memberships"; +import { addOrRefreshProgram, joinExternal, listDirectory, listJoins, syncJoin } from "@/lib/affiliate/directory"; +import { requestPayout } from "@/lib/affiliate/payouts"; +import { termsLine } from "@/lib/affiliate/program"; +import { isPayAddress } from "@/lib/affiliate/spec"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getUserId(extra: any): string { + const info = extra?.authInfo; + const uid = info?.extra?.userId ?? info?.clientId; + if (!uid || typeof uid !== "string") throw new Error("Unauthenticated."); + return uid; +} +function textResult(s: string) { + return { content: [{ type: "text" as const, text: s }] }; +} +async function userOf(userId: string) { + const { data } = await serviceClient().from("profiles").select("email, display_name").eq("id", userId).maybeSingle(); + return { id: userId, email: (data?.email as string | null) ?? null, displayName: (data?.display_name as string | null) ?? null }; +} + +export function registerAffiliateTools(server: McpServer): void { + server.registerTool( + "affiliate_link", + { + description: + "The caller's affiliate link, code and balances in the program CrawlProof runs (OpenAffiliate). Share the link; a purchase within 30 days of a click pays a commission after a 30-day hold, in USDC on Polygon.", + inputSchema: {}, + }, + async (_args, extra) => { + const m = await ensureMembershipForUser(await userOf(getUserId(extra))); + if (!m) return textResult("The affiliate program is not available on this deployment."); + const ledger = await ledgerFor(m); + return textResult( + [ + `link: ${ledger.link}`, + `code: ${m.code}`, + `profile: ${profileUrlForMembership(m)}`, + `terms: ${termsLine()}`, + `clicks: ${ledger.clicks.total} all time, ${ledger.clicks.window} in the window`, + `balance: pending $${ledger.balance.pending.toFixed(2)}, approved $${ledger.balance.approved.toFixed(2)}, paid $${ledger.balance.paid.toFixed(2)}`, + `payout address: ${m.payAddress ?? "not set (affiliate_set_payout_address)"}`, + ].join("\n"), + ); + }, + ); + + server.registerTool( + "affiliate_ledger", + { + description: "The caller's affiliate ledger: every conversion with status, hold and reason, and every payout with its tx. JSON, the same shape a third party reads with an oa_ token.", + inputSchema: { since: z.string().optional().describe("ISO 8601; rows created or changed since") }, + }, + async (args, extra) => { + const m = await ensureMembershipForUser(await userOf(getUserId(extra))); + if (!m) return textResult("No membership."); + const since = args.since ? new Date(args.since) : null; + const ledger = await ledgerFor(m, since && !Number.isNaN(since.getTime()) ? since : null); + return textResult(JSON.stringify(ledger, null, 2)); + }, + ); + + server.registerTool( + "affiliate_set_payout_address", + { + description: "Set the wallet the caller's affiliate commission is paid to (USDC on Polygon). An EVM address, 0x and 42 characters.", + inputSchema: { address: z.string().describe("0x… address, or empty to clear") }, + }, + async (args, extra) => { + const m = await ensureMembershipForUser(await userOf(getUserId(extra))); + if (!m) return textResult("No membership."); + const a = args.address.trim(); + if (a && !isPayAddress(a)) return textResult("That is not a wallet address. It starts with 0x and is 42 characters."); + const out = await setPayAddress(m.id, a || null); + return textResult(out.ok ? `Payout address ${a ? `set to ${a}` : "cleared"}.` : out.error); + }, + ); + + server.registerTool( + "affiliate_payout", + { description: "Send the caller's approved affiliate balance to their payout address now, if it is over the program minimum.", inputSchema: {} }, + async (_args, extra) => { + const m = await ensureMembershipForUser(await userOf(getUserId(extra))); + if (!m) return textResult("No membership."); + const out = await requestPayout(m); + return textResult(out.ok ? `Sent $${(out.amountCents / 100).toFixed(2)}${out.txHash ? ` (tx ${out.txHash})` : ` (${out.status})`}.` : out.error); + }, + ); + + server.registerTool( + "affiliate_programs", + { + description: "The directory of other merchants' OpenAffiliate programs, each read from the merchant's own /.well-known/openaffiliate.json: what it pays, window, hold, payout, approval. Pass a url to read a new merchant first.", + inputSchema: { url: z.string().optional().describe("A merchant URL to read and add before listing") }, + }, + async (args, extra) => { + const userId = getUserId(extra); + const notes: string[] = []; + if (args.url) { + const added = await addOrRefreshProgram(args.url, userId); + notes.push(added.ok ? `Read ${added.row.origin}${added.warnings.length ? ` (${added.warnings.join("; ")})` : ""}.` : `Could not read ${args.url}: ${added.error}`); + } + const rows = await listDirectory(); + if (!rows.length) return textResult([...notes, "No merchants read yet."].join("\n")); + const lines = rows.map((p) => { + const pays = p.program.pays.map((x) => `${x.kind === "percent" ? `${x.value}%` : `$${x.value}`} per ${x.event}${x.months ? ` for ${x.months} months` : ""}`).join(", "); + return `- ${p.merchant.name} (${p.origin}) program "${p.program.id}": ${pays}; window ${p.program.window ?? "unstated"} days; hold ${p.program.hold_days ?? "unstated"} days; payout ${p.program.payout?.methods.join("/") ?? "unstated"}; ${p.program.approval}; ${p.verified ? "verified" : "claimed"}`; + }); + return textResult([...notes, ...lines].join("\n")); + }, + ); + + server.registerTool( + "affiliate_join", + { + description: "Join another merchant's OpenAffiliate program as the caller, with the caller's CrawlProof profile and payout address. Returns the link to share and the join status.", + inputSchema: { + origin: z.string().describe("The merchant's URL or origin"), + program: z.string().optional().describe("Program id when the merchant runs several"), + code: z.string().optional().describe("Preferred code, 3 to 32 lower-case letters, digits or dashes"), + }, + }, + async (args, extra) => { + const out = await joinExternal(await userOf(getUserId(extra)), { origin: args.origin, programId: args.program, code: args.code }); + if (!out.ok) return textResult(out.error); + const j = out.join; + return textResult(`${out.existing ? "Already joined" : "Joined"} ${j.origin} program ${j.programId}: ${j.status}.${j.link ? ` Link: ${j.link}` : ""}`); + }, + ); + + server.registerTool( + "affiliate_joined", + { + description: "The programs the caller has joined elsewhere, with each ledger's balances. Pass sync=true to re-read every ledger first.", + inputSchema: { sync: z.boolean().optional() }, + }, + async (args, extra) => { + const userId = getUserId(extra); + if (args.sync) for (const j of await listJoins(userId)) await syncJoin(j.id, userId); + const joins = await listJoins(userId); + if (!joins.length) return textResult("No programs joined yet. Use affiliate_programs to find one and affiliate_join to join it."); + return textResult( + joins + .map((j) => { + const bal = (j.ledger?.balance ?? null) as { pending?: number; approved?: number; paid?: number } | null; + return `- ${j.origin} ${j.programId}: ${j.status}${bal ? `; pending $${(bal.pending ?? 0).toFixed(2)}, approved $${(bal.approved ?? 0).toFixed(2)}, paid $${(bal.paid ?? 0).toFixed(2)}` : "; no ledger read"}${j.link ? `; link ${j.link}` : ""}${j.error ? `; ${j.error}` : ""}`; + }) + .join("\n"), + ); + }, + ); +} diff --git a/proxy.ts b/proxy.ts index ff657cea..f9e594ec 100644 --- a/proxy.ts +++ b/proxy.ts @@ -2,6 +2,7 @@ import { gate } from "@/lib/crawl-gateway"; import { NextResponse, type NextRequest } from "next/server"; import { createServerClient, type CookieOptions } from "@supabase/ssr"; import { trackReferralCode } from "@profullstack/stack/referrals"; +import { isNavigation } from "@/lib/affiliate/cookie"; type Cookie = { name: string; value: string; options?: CookieOptions }; @@ -22,6 +23,27 @@ export async function proxy(request: NextRequest) { return NextResponse.redirect(target, 308); } + // OpenAffiliate: a navigation that carries ?oa= goes through the click + // route, which records the click, sets the attribution cookie and comes back + // to the same path without the parameter. Only a navigation: an image, a + // frame, a script or a prefetch carrying the parameter sets nothing, which + // is the whole defence against cookie stuffing. The click route itself and + // the API are excluded so the redirect cannot loop. + { + const oa = request.nextUrl.searchParams.get("oa"); + const p = request.nextUrl.pathname; + if (oa && !p.startsWith("/api/") && !p.startsWith("/_next/") && isNavigation(request.headers)) { + const clean = request.nextUrl.clone(); + clean.searchParams.delete("oa"); + const click = request.nextUrl.clone(); + click.pathname = "/api/affiliate/v1/click"; + click.search = ""; + click.searchParams.set("oa", oa.toLowerCase()); + click.searchParams.set("to", `${clean.pathname}${clean.search}`); + return NextResponse.redirect(click, 302); + } + } + let response = NextResponse.next({ request }); const supabase = createServerClient( diff --git a/supabase/migrations/20260913120000_openaffiliate.sql b/supabase/migrations/20260913120000_openaffiliate.sql new file mode 100644 index 00000000..990ec6eb --- /dev/null +++ b/supabase/migrations/20260913120000_openaffiliate.sql @@ -0,0 +1,370 @@ +-- OpenAffiliate: the affiliate program CrawlProof runs, and the programs its +-- users join elsewhere. Spec: https://logicsrc.com/docs/openaffiliate +-- +-- Apply ONE FILE AT A TIME via the Supabase MCP against ywcizjsgrcmhgyplldac +-- (prod's migration history has diverged from this directory, so `db push` +-- would replay files prod already has). Nothing here backfills a row that +-- serving reads; the code treats a missing table as "no affiliate program". +-- +-- Why this is not the 2026-06 referral tables. `referral_codes` and +-- `referral_usages` were issued by an npm package that captured a cookie and +-- never recorded a commission: no code in this repo writes referral_usages. +-- The affiliate rail needs the half that was missing, a conversion with a +-- hold, a reason on reversal and a payout with a tx, and it needs to be +-- readable by the affiliate through a bearer token, because that is what the +-- spec promises. So it is its own set of tables, and the old ones stay put. +-- +-- Two sides live here: +-- +-- MERCHANT (we run a program): affiliate_memberships, affiliate_clicks, +-- affiliate_attributions, affiliate_conversions, affiliate_payouts and +-- affiliate_events (the outbound webhook queue). +-- +-- AFFILIATE (our users join other merchants' programs): affiliate_programs +-- (a directory of descriptors read from /.well-known/openaffiliate.json) +-- and affiliate_joins (one row per user per program, holding the token the +-- merchant handed back and the last ledger read). +-- +-- Amounts are CENTS. A commission on a credits pack is dollars, not fractions +-- of a cent, so the earn rail's micros are not needed here. + +-- ───────────────────────────────────────────────────────────────────────────── +-- Memberships: one affiliate in our program +-- ───────────────────────────────────────────────────────────────────────────── +-- An affiliate is a CrawlProof user (owner_id) or an outside party identified +-- by an OpenProfile.md URL (profile_url), or both. `code` is what goes in the +-- link (?oa=code). The token is stored hashed with the same pepper as API +-- tokens; the plaintext is shown once at join. `terms` is the program's `pays` +-- as it stood at the join, so the affiliate keeps a copy of what it agreed to. +create table if not exists public.affiliate_memberships ( + id uuid primary key default gen_random_uuid(), + program text not null default 'partners', + owner_id uuid references auth.users(id) on delete set null, + profile_url text, + kind text not null default 'person' check (kind in ('person','agent','organization')), + display_name text, + email text, + pay_address text, + webhook_url text, + code text not null check (code ~ '^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$'), + token_prefix text not null, + token_hash text not null, + status text not null default 'active' + check (status in ('active','pending','refused','ended')), + terms jsonb not null default '[]'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint affiliate_memberships_party check (owner_id is not null or profile_url is not null) +); +create unique index if not exists affiliate_memberships_code_idx + on public.affiliate_memberships(program, lower(code)); +create unique index if not exists affiliate_memberships_token_idx + on public.affiliate_memberships(token_hash); +create unique index if not exists affiliate_memberships_owner_idx + on public.affiliate_memberships(program, owner_id) where owner_id is not null; +create unique index if not exists affiliate_memberships_profile_idx + on public.affiliate_memberships(program, lower(profile_url)) where profile_url is not null; +comment on table public.affiliate_memberships is + 'One affiliate in a program CrawlProof runs. code is the ?oa= value; token_hash is the peppered sha256 of the oa_ bearer token.'; + +-- ───────────────────────────────────────────────────────────────────────────── +-- Clicks: a navigation that carried ?oa= +-- ───────────────────────────────────────────────────────────────────────────── +-- Only a navigation lands here (the middleware ignores the parameter on an +-- image, frame, script or prefetch). One row per click; the cookie set with +-- it is what a later purchase is attributed through. +create table if not exists public.affiliate_clicks ( + id uuid primary key default gen_random_uuid(), + membership_id uuid not null references public.affiliate_memberships(id) on delete cascade, + landing text, + referrer text, + ip_hash text, + user_agent text, + at timestamptz not null default now() +); +create index if not exists affiliate_clicks_membership_idx + on public.affiliate_clicks(membership_id, at desc); + +-- ───────────────────────────────────────────────────────────────────────────── +-- Attributions: which affiliate a signed-in user belongs to right now +-- ───────────────────────────────────────────────────────────────────────────── +-- Written when a user with the cookie signs in or starts a purchase. One row +-- per user: last-touch replaces it while the window is open (that is the +-- program's `attribution: last`), and it expires `window` days after the click +-- it came from. The purchase webhook, which has no cookie, reads this. +create table if not exists public.affiliate_attributions ( + user_id uuid primary key references auth.users(id) on delete cascade, + membership_id uuid not null references public.affiliate_memberships(id) on delete cascade, + code text not null, + clicked_at timestamptz not null, + expires_at timestamptz not null, + set_at timestamptz not null default now() +); +create index if not exists affiliate_attributions_membership_idx + on public.affiliate_attributions(membership_id); + +-- ───────────────────────────────────────────────────────────────────────────── +-- Conversions: one event the program pays for +-- ───────────────────────────────────────────────────────────────────────────── +-- pending from the moment it is recorded, approved once held_until passes +-- without a refund, reversed with a reason when we take it back, paid when a +-- payout covers it. (event, order_ref) is the idempotency key, so a webhook +-- retry or a poll fallback cannot record a purchase twice. customer_id is +-- kept for our own reconciliation and is never sent to the affiliate. +create table if not exists public.affiliate_conversions ( + id uuid primary key default gen_random_uuid(), + membership_id uuid not null references public.affiliate_memberships(id) on delete cascade, + event text not null check (event in ('sale','subscription','signup','lead','install','other')), + order_ref text not null, + customer_id uuid, + amount_cents integer not null default 0 check (amount_cents >= 0), + commission_cents integer not null default 0 check (commission_cents >= 0), + currency text not null default 'USD', + status text not null default 'pending' + check (status in ('pending','approved','reversed','paid')), + held_until timestamptz, + reason text, + recurring_n integer, + recurring_of integer, + payout_id uuid, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + -- A reversal without a reason is not a reversal (spec, "The ledger"). + constraint affiliate_conversions_reason check (status <> 'reversed' or reason is not null) +); +create unique index if not exists affiliate_conversions_order_idx + on public.affiliate_conversions(event, order_ref); +create index if not exists affiliate_conversions_membership_idx + on public.affiliate_conversions(membership_id, created_at desc); +create index if not exists affiliate_conversions_due_idx + on public.affiliate_conversions(held_until) where status = 'pending'; + +-- ───────────────────────────────────────────────────────────────────────────── +-- Payouts: approved balance leaving through CoinPay +-- ───────────────────────────────────────────────────────────────────────────── +create table if not exists public.affiliate_payouts ( + id uuid primary key default gen_random_uuid(), + membership_id uuid not null references public.affiliate_memberships(id) on delete cascade, + amount_cents integer not null check (amount_cents > 0), + method text not null default 'usdc/eip155:137', + pay_address text not null, + status text not null default 'requested' + check (status in ('requested','sent','failed')), + coinpay_payout_id text, + tx_hash text, + error text, + created_at timestamptz not null default now(), + settled_at timestamptz +); +create index if not exists affiliate_payouts_membership_idx + on public.affiliate_payouts(membership_id, created_at desc); + +-- ───────────────────────────────────────────────────────────────────────────── +-- Events: the outbound webhook queue +-- ───────────────────────────────────────────────────────────────────────────── +-- One row per event per membership that gave a webhook URL. Delivered by the +-- hourly cron with backoff; after a day of failures it stops, and the ledger +-- still has the row (spec, "Webhooks"). +create table if not exists public.affiliate_events ( + id uuid primary key default gen_random_uuid(), + membership_id uuid not null references public.affiliate_memberships(id) on delete cascade, + event text not null, + payload jsonb not null, + attempts integer not null default 0, + next_attempt_at timestamptz not null default now(), + delivered_at timestamptz, + last_error text, + created_at timestamptz not null default now() +); +create index if not exists affiliate_events_due_idx + on public.affiliate_events(next_attempt_at) where delivered_at is null; + +-- ───────────────────────────────────────────────────────────────────────────── +-- Programs: the directory of merchants read from their own origin +-- ───────────────────────────────────────────────────────────────────────────── +-- `descriptor` is the merchant's file as fetched, unchanged. `verified` is +-- whether it came from the merchant's own /.well-known/ (spec, "Discovery"). +create table if not exists public.affiliate_programs ( + id uuid primary key default gen_random_uuid(), + origin text not null, + descriptor jsonb, + verified boolean not null default false, + fetched_at timestamptz, + error text, + added_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create unique index if not exists affiliate_programs_origin_idx + on public.affiliate_programs(lower(origin)); + +-- ───────────────────────────────────────────────────────────────────────────── +-- Joins: our users' memberships in other merchants' programs +-- ───────────────────────────────────────────────────────────────────────────── +-- The token is the merchant's credential for the user's own ledger; we hold +-- it for the user, not against them (spec, "Directories" rule 4), so it is +-- readable only through the owner's session or API token. +create table if not exists public.affiliate_joins ( + id uuid primary key default gen_random_uuid(), + owner_id uuid not null references auth.users(id) on delete cascade, + origin text not null, + program_id text not null, + membership_ref text, + code text, + link text, + token text, + ledger_url text, + status text not null default 'pending' + check (status in ('active','pending','refused','ended')), + terms jsonb, + ledger jsonb, + events jsonb not null default '[]'::jsonb, + synced_at timestamptz, + error text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create unique index if not exists affiliate_joins_owner_program_idx + on public.affiliate_joins(owner_id, lower(origin), program_id); + +-- ───────────────────────────────────────────────────────────────────────────── +-- RLS: owners read their own rows; every write goes through the service role +-- ───────────────────────────────────────────────────────────────────────────── +alter table public.affiliate_memberships enable row level security; +alter table public.affiliate_clicks enable row level security; +alter table public.affiliate_attributions enable row level security; +alter table public.affiliate_conversions enable row level security; +alter table public.affiliate_payouts enable row level security; +alter table public.affiliate_events enable row level security; +alter table public.affiliate_programs enable row level security; +alter table public.affiliate_joins enable row level security; + +drop policy if exists "own affiliate membership" on public.affiliate_memberships; +create policy "own affiliate membership" on public.affiliate_memberships + for select using (owner_id = auth.uid()); + +drop policy if exists "own affiliate clicks" on public.affiliate_clicks; +create policy "own affiliate clicks" on public.affiliate_clicks + for select using (exists ( + select 1 from public.affiliate_memberships m + where m.id = affiliate_clicks.membership_id and m.owner_id = auth.uid())); + +drop policy if exists "own affiliate conversions" on public.affiliate_conversions; +create policy "own affiliate conversions" on public.affiliate_conversions + for select using (exists ( + select 1 from public.affiliate_memberships m + where m.id = affiliate_conversions.membership_id and m.owner_id = auth.uid())); + +drop policy if exists "own affiliate payouts" on public.affiliate_payouts; +create policy "own affiliate payouts" on public.affiliate_payouts + for select using (exists ( + select 1 from public.affiliate_memberships m + where m.id = affiliate_payouts.membership_id and m.owner_id = auth.uid())); + +-- The directory is public by nature: it is other merchants' public files. +drop policy if exists "affiliate programs are public" on public.affiliate_programs; +create policy "affiliate programs are public" on public.affiliate_programs + for select using (true); + +drop policy if exists "own affiliate joins" on public.affiliate_joins; +create policy "own affiliate joins" on public.affiliate_joins + for select using (owner_id = auth.uid()); + +-- ───────────────────────────────────────────────────────────────────────────── +-- Payout: move approved conversions to paid under one lock +-- ───────────────────────────────────────────────────────────────────────────── +-- Debits first, sends second (the same order as earn_request_payout): the +-- conversions flip to paid inside this transaction, CoinPay is called by the +-- app afterwards, and a failed send calls affiliate_fail_payout to put them +-- back. Money can therefore be delayed by a CoinPay outage but never sent +-- twice. Every table reference is aliased and qualified; see the note in +-- earn_rail about RETURNS TABLE ambiguity. +create or replace function public.affiliate_request_payout( + p_membership uuid, + p_method text, + p_address text, + p_min_cents integer default 0 +) returns table (payout_id uuid, amount_cents integer) +language plpgsql +security definer +set search_path = public +as $$ +declare + v_total integer; + v_payout uuid; +begin + if p_address is null or length(trim(p_address)) = 0 then + raise exception 'affiliate_request_payout: no payout address on the membership'; + end if; + + -- Serialise on the membership so two requests cannot both sum the same rows. + perform 1 from public.affiliate_memberships m where m.id = p_membership for update; + if not found then + raise exception 'affiliate_request_payout: no such membership'; + end if; + + select coalesce(sum(c.commission_cents), 0) into v_total + from public.affiliate_conversions c + where c.membership_id = p_membership and c.status = 'approved'; + + if v_total <= 0 then + raise exception 'affiliate_request_payout: nothing approved to pay'; + end if; + if v_total < p_min_cents then + raise exception 'affiliate_request_payout: approved balance is below the program minimum'; + end if; + + insert into public.affiliate_payouts (membership_id, amount_cents, method, pay_address) + values (p_membership, v_total, p_method, p_address) + returning id into v_payout; + + update public.affiliate_conversions c + set status = 'paid', payout_id = v_payout, updated_at = now() + where c.membership_id = p_membership and c.status = 'approved'; + + return query select v_payout, v_total; +end; +$$; + +create or replace function public.affiliate_fail_payout(p_payout uuid, p_error text) +returns void +language plpgsql +security definer +set search_path = public +as $$ +begin + update public.affiliate_payouts p + set status = 'failed', error = left(coalesce(p_error, 'failed'), 500) + where p.id = p_payout and p.status = 'requested'; + if not found then + return; + end if; + update public.affiliate_conversions c + set status = 'approved', payout_id = null, updated_at = now() + where c.payout_id = p_payout and c.status = 'paid'; +end; +$$; + +revoke all on function public.affiliate_request_payout(uuid, text, text, integer) from public, anon, authenticated; +revoke all on function public.affiliate_fail_payout(uuid, text) from public, anon, authenticated; +grant execute on function public.affiliate_request_payout(uuid, text, text, integer) to service_role; +grant execute on function public.affiliate_fail_payout(uuid, text) to service_role; + +-- ───────────────────────────────────────────────────────────────────────────── +-- Cron: approve what is past its hold, deliver webhooks, pay on schedule, +-- re-read the directory. The route does the work; this only rings the bell. +-- ───────────────────────────────────────────────────────────────────────────── +select cron.schedule( + 'crawlproof-affiliate', + '23 * * * *', + $cron$ + select net.http_post( + url := current_setting('app.site_url', true) || '/api/cron/affiliate', + headers := jsonb_build_object( + 'content-type', 'application/json', + 'x-cron-secret', current_setting('app.cron_secret', true) + ), + body := '{}'::jsonb + ); + $cron$ +); diff --git a/tests/affiliate-cli.test.ts b/tests/affiliate-cli.test.ts new file mode 100644 index 00000000..517ae0d1 --- /dev/null +++ b/tests/affiliate-cli.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { affiliateJoinBodyFromArgs, conversionLine, parseArgs } from "@/cli/index"; + +describe("crawlproof affiliate", () => { + it("join body from flags", () => { + expect(affiliateJoinBodyFromArgs(parseArgs(["affiliate", "join", "nichedb.dev"]))).toEqual({ origin: "nichedb.dev" }); + expect(affiliateJoinBodyFromArgs(parseArgs(["affiliate", "join", "https://nichedb.dev", "--program=partners", "--code", "chovy"]))).toEqual({ + origin: "https://nichedb.dev", + program: "partners", + code: "chovy", + }); + }); + it("conversion lines carry the hold and the reason", () => { + expect(conversionLine({ at: "2026-09-12T14:02:11Z", event: "sale", amount: 49, commission: 14.7, status: "pending", held_until: "2026-10-12T14:02:11Z" })).toBe( + "2026-09-12 sale $ 49.00 → $ 14.70 pending until 2026-10-12", + ); + expect(conversionLine({ at: "2026-08-30T09:15:00Z", event: "sale", amount: 29, commission: 8.7, status: "reversed", reason: "refunded 2026-09-04" })).toContain("(refunded 2026-09-04)"); + }); +}); diff --git a/tests/affiliate-cookie.test.ts b/tests/affiliate-cookie.test.ts new file mode 100644 index 00000000..e4a94cb0 --- /dev/null +++ b/tests/affiliate-cookie.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { decodeCookie, encodeCookie, expiresAt, isNavigation, withinWindow } from "@/lib/affiliate/cookie"; + +describe("attribution cookie", () => { + it("round-trips", () => { + const at = new Date("2026-09-13T12:00:00Z"); + const v = encodeCookie({ code: "anthony", clickedAt: at }); + expect(v).toBe("anthony.1789300800"); + expect(decodeCookie(v)).toEqual({ code: "anthony", clickedAt: at }); + }); + it("refuses garbage", () => { + expect(decodeCookie("")).toBeNull(); + expect(decodeCookie("anthony")).toBeNull(); + expect(decodeCookie("Bad.1789344000")).toBeNull(); + expect(decodeCookie("anthony.-5")).toBeNull(); + expect(decodeCookie("anthony.notanumber")).toBeNull(); + }); + it("window arithmetic", () => { + const at = new Date("2026-09-01T00:00:00Z"); + expect(expiresAt(at, 30).toISOString()).toBe("2026-10-01T00:00:00.000Z"); + expect(withinWindow(at, 30, new Date("2026-09-30T23:59:00Z"))).toBe(true); + expect(withinWindow(at, 30, new Date("2026-10-01T00:00:01Z"))).toBe(false); + // a click from the future is not a click + expect(withinWindow(new Date("2026-09-02T00:00:00Z"), 30, at)).toBe(false); + }); +}); + +describe("isNavigation", () => { + const h = (o: Record) => ({ get: (k: string) => o[k.toLowerCase()] ?? null }); + it("only a document navigation sets attribution", () => { + expect(isNavigation(h({ "sec-fetch-dest": "document", "sec-fetch-mode": "navigate" }))).toBe(true); + expect(isNavigation(h({ "sec-fetch-dest": "image", "sec-fetch-mode": "no-cors" }))).toBe(false); + expect(isNavigation(h({ "sec-fetch-dest": "iframe", "sec-fetch-mode": "navigate" }))).toBe(false); + expect(isNavigation(h({ "sec-fetch-dest": "script", "sec-fetch-mode": "no-cors" }))).toBe(false); + expect(isNavigation(h({ "sec-fetch-dest": "empty", "sec-fetch-mode": "cors" }))).toBe(false); + }); + it("falls back to Accept when the fetch metadata headers are absent", () => { + expect(isNavigation(h({ accept: "text/html,application/xhtml+xml" }))).toBe(true); + expect(isNavigation(h({ accept: "image/webp,*/*" }))).toBe(false); + expect(isNavigation(h({}))).toBe(false); + }); +}); diff --git a/tests/affiliate-spec.test.ts b/tests/affiliate-spec.test.ts new file mode 100644 index 00000000..54ea0117 --- /dev/null +++ b/tests/affiliate-spec.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import { + codeFromProfile, + commissionCents, + isAffiliateCode, + isPayAddress, + isVerifiedOrigin, + linkFor, + parseDescriptor, + parseJoinRequest, + readProfile, + walletOf, +} from "@/lib/affiliate/spec"; +import { ourDescriptorJson } from "@/lib/affiliate/program"; + +const FULL = { + merchant: { name: "Northwind", web: "https://northwind.example", currency: "usd", terms: "https://northwind.example/terms", region: "EU" }, + updated: "2026-09-13T06:00:00Z", + programs: [ + { + id: "partners", + title: "Partners", + join: "https://northwind.example/join", + ledger: "https://northwind.example/ledger", + approval: "open", + pays: [ + { event: "sale", kind: "percent", value: 30 }, + { event: "subscription", kind: "percent", value: 30, months: 12 }, + { event: "signup", kind: "amount", value: 0.5 }, + { event: "view", kind: "amount", value: 1 }, + ], + link: { param: "ref", deep: true, aliases: ["oa"] }, + window: 30, + attribution: "first", + hold_days: 14, + payout: { methods: ["usdc/eip155:137"], min: 10, schedule: "weekly" }, + self: "allowed", + status: "active", + tier: "gold", + }, + ], +}; + +describe("parseDescriptor", () => { + it("reads the full example and keeps unknown keys", () => { + const out = parseDescriptor(FULL); + expect(out.ok).toBe(true); + if (!out.ok) return; + const p = out.descriptor.programs[0]; + expect(out.descriptor.merchant.currency).toBe("USD"); + expect(out.descriptor.merchant.extra).toEqual({ region: "EU" }); + expect(p.id).toBe("partners"); + expect(p.pays).toHaveLength(3); + expect(p.link).toEqual({ param: "ref", template: undefined, deep: true, aliases: ["oa"] }); + expect(p.attribution).toBe("first"); + expect(p.self).toBe("allowed"); + expect(p.payout).toEqual({ methods: ["usdc/eip155:137"], min: 10, schedule: "weekly" }); + expect(p.extra).toEqual({ tier: "gold" }); + expect(out.warnings.join(" ")).toContain("view"); + }); + + it("accepts the smallest valid descriptor with defaults", () => { + const out = parseDescriptor({ merchant: { name: "N" }, programs: [{ title: "Partners", pays: [{ event: "sale", kind: "percent", value: 10 }] }] }); + expect(out.ok).toBe(true); + if (!out.ok) return; + const p = out.descriptor.programs[0]; + expect(p.id).toBe("partners"); + expect(p.approval).toBe("review"); + expect(p.attribution).toBe("last"); + expect(p.self).toBe("refused"); + expect(p.status).toBe("active"); + expect(p.link.param).toBe("oa"); + expect(p.window).toBeUndefined(); + }); + + it("refuses what cannot pay", () => { + expect(parseDescriptor(null)).toMatchObject({ ok: false }); + expect(parseDescriptor({ merchant: {}, programs: [] })).toMatchObject({ ok: false, error: expect.stringContaining("merchant.name") }); + expect(parseDescriptor({ merchant: { name: "N" }, programs: [] })).toMatchObject({ ok: false, error: expect.stringContaining("no programs") }); + expect(parseDescriptor({ merchant: { name: "N" }, programs: [{ title: "T", pays: [{ event: "sale", kind: "percent", value: 250 }] }] })).toMatchObject({ ok: false }); + }); + + it("drops a duplicate program id", () => { + const out = parseDescriptor({ + merchant: { name: "N" }, + programs: [ + { id: "a", title: "A", pays: [{ event: "sale", kind: "amount", value: 1 }] }, + { id: "a", title: "A again", pays: [{ event: "sale", kind: "amount", value: 2 }] }, + ], + }); + expect(out.ok && out.descriptor.programs).toHaveLength(1); + }); + + it("our own descriptor parses as verified and pays a sale", () => { + const out = parseDescriptor(ourDescriptorJson("https://crawlproof.com", "2026-09-13T00:00:00Z")); + expect(out.ok).toBe(true); + if (!out.ok) return; + expect(out.warnings).toEqual([]); + const p = out.descriptor.programs[0]; + expect(p.join).toBe("https://crawlproof.com/api/affiliate/v1/join"); + expect(p.approval).toBe("open"); + expect(commissionCents(p.pays, "sale", 4900)).toBe(1470); + expect(isVerifiedOrigin("https://crawlproof.com/.well-known/openaffiliate.json", out.descriptor.merchant.web)).toBe(true); + }); +}); + +describe("commissionCents", () => { + const pays = parseDescriptor(FULL); + const p = pays.ok ? pays.descriptor.programs[0].pays : []; + it("percent of the amount, rounded to a cent", () => { + expect(commissionCents(p, "sale", 4900)).toBe(1470); + expect(commissionCents(p, "sale", 1)).toBe(0); + expect(commissionCents(p, "sale", 333)).toBe(100); + }); + it("flat amounts and unpaid events", () => { + expect(commissionCents(p, "signup", 0)).toBe(50); + expect(commissionCents(p, "lead", 10000)).toBe(0); + }); + it("subscription renewals stop paying after months", () => { + expect(commissionCents(p, "subscription", 1000, 1)).toBe(300); + expect(commissionCents(p, "subscription", 1000, 12)).toBe(300); + expect(commissionCents(p, "subscription", 1000, 13)).toBe(0); + }); +}); + +describe("linkFor", () => { + const program = { link: { param: "oa", deep: true, aliases: [] as string[] }, url: "https://m.example/partners" }; + it("deep-links when allowed, else web with the param", () => { + expect(linkFor(program, "anthony", "https://m.example", "https://m.example/pricing?x=1")).toBe("https://m.example/pricing?x=1&oa=anthony"); + expect(linkFor(program, "anthony", "https://m.example")).toBe("https://m.example/?oa=anthony"); + expect(linkFor({ ...program, link: { ...program.link, deep: false } }, "a", "https://m.example", "https://m.example/p")).toBe("https://m.example/?oa=a"); + }); + it("uses the template when given", () => { + expect(linkFor({ link: { param: "oa", template: "https://m.example/go/{code}", deep: false, aliases: [] } }, "a b")).toBe("https://m.example/go/a%20b"); + }); + it("returns null with nothing to link to", () => { + expect(linkFor({ link: { param: "oa", deep: false, aliases: [] } }, "a")).toBeNull(); + }); +}); + +describe("join request", () => { + it("needs a profile URL and validates the rest", () => { + expect(parseJoinRequest({})).toMatchObject({ ok: false, error: expect.stringContaining("profile") }); + expect(parseJoinRequest({ profile: "ftp://x" })).toMatchObject({ ok: false }); + expect(parseJoinRequest({ profile: "https://a.example/.well-known/openprofile.md", pay: "nope" })).toMatchObject({ ok: false, error: expect.stringContaining("pay") }); + expect(parseJoinRequest({ profile: "https://a.example/p.md", webhook: "http://insecure" })).toMatchObject({ ok: false, error: expect.stringContaining("webhook") }); + expect(parseJoinRequest({ profile: "https://a.example/p.md", code: "Bad Code" })).toMatchObject({ ok: false, error: expect.stringContaining("code") }); + expect( + parseJoinRequest({ profile: "https://a.example/p.md", pay: "eip155:137:0xCC3b072391AE7A8d10cF00DdC5F61DB2cA5541E5", webhook: "https://a.example/hook", code: "anthony", program: "partners" }), + ).toEqual({ + ok: true, + request: { profile: "https://a.example/p.md", pay: "eip155:137:0xCC3b072391AE7A8d10cF00DdC5F61DB2cA5541E5", webhook: "https://a.example/hook", code: "anthony", program: "partners" }, + }); + }); + it("codes and pay addresses", () => { + expect(isAffiliateCode("abc")).toBe(true); + expect(isAffiliateCode("a-b-c-1")).toBe(true); + expect(isAffiliateCode("ab")).toBe(false); + expect(isAffiliateCode("-abc")).toBe(false); + expect(isAffiliateCode("ABC")).toBe(false); + expect(isPayAddress("0xCC3b072391AE7A8d10cF00DdC5F61DB2cA5541E5")).toBe(true); + expect(isPayAddress("eip155:8453:0xCC3b072391AE7A8d10cF00DdC5F61DB2cA5541E5")).toBe(true); + expect(isPayAddress("0x123")).toBe(false); + expect(walletOf("eip155:137:0xabc")).toBe("0xabc"); + }); +}); + +describe("readProfile", () => { + const md = `# Anthony Ettinger + +Kind: person +Handle: @chovy +Email: +Pay: eip155:137:0xCC3b072391AE7A8d10cF00DdC5F61DB2cA5541E5 + +Builds things. + +## Accounts + +- https://github.com/chovy +- https://crawlproof.com/?oa=chovy + +## Operator + +- Name: Profullstack +- Profile: https://profullstack.com/.well-known/openprofile.md +`; + it("reads the identity block and accounts", () => { + const f = readProfile(md); + expect(f.name).toBe("Anthony Ettinger"); + expect(f.kind).toBe("person"); + expect(f.handle).toBe("chovy"); + expect(f.email).toBe("anthony@profullstack.com"); + expect(f.pay).toBe("eip155:137:0xCC3b072391AE7A8d10cF00DdC5F61DB2cA5541E5"); + expect(f.accounts).toEqual(["https://github.com/chovy", "https://crawlproof.com/?oa=chovy"]); + expect(f.operator).toBe("https://profullstack.com/.well-known/openprofile.md"); + }); + it("suggests a code from the handle, the name, then the host", () => { + expect(codeFromProfile("https://x.example/p.md", readProfile(md))).toBe("chovy"); + expect(codeFromProfile("https://x.example/p.md", { name: "Jane Q. Public" })).toBe("jane-q-public"); + expect(codeFromProfile("https://nichedb.dev/.well-known/openprofile.md")).toBe("nichedb"); + expect(codeFromProfile("not a url")).toBe("partner"); + }); +}); diff --git a/tests/affiliate-webhooks.test.ts b/tests/affiliate-webhooks.test.ts new file mode 100644 index 00000000..4566374d --- /dev/null +++ b/tests/affiliate-webhooks.test.ts @@ -0,0 +1,29 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import crypto from "node:crypto"; + +// env is read at call time in lib/affiliate/webhooks, but lib/env.ts is a +// snapshot at import; set the variable before the first import. +const seed = crypto.randomBytes(32).toString("base64url"); +process.env.OPENAFFILIATE_SIGNING_KEY = seed; + +describe("webhook signing", () => { + let mod: typeof import("@/lib/affiliate/webhooks"); + beforeEach(async () => { + mod = await import("@/lib/affiliate/webhooks"); + }); + afterEach(() => { + delete process.env.OPENAFFILIATE_SIGNING_KEY; + }); + + it("serves a JWK and verifies its own signature", () => { + const jwk = mod.publicJwk(); + expect(jwk).toMatchObject({ kty: "OKP", crv: "Ed25519", kid: mod.KEY_ID, alg: "EdDSA" }); + const body = JSON.stringify({ event: "conversion.approved", at: "2026-09-13T00:00:00Z" }); + const sig = mod.signBody(body); + expect(sig).toBeTruthy(); + expect(mod.verifySignature(body, `ed25519=${sig}`, jwk!)).toBe(true); + expect(mod.verifySignature(body + " ", `ed25519=${sig}`, jwk!)).toBe(false); + expect(mod.verifySignature(body, null, jwk!)).toBe(false); + expect(mod.verifySignature(body, "hmac=abc", jwk!)).toBe(false); + }); +}); From 537288935a3070316b7820ec1aa486bae173a3a4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 02:51:37 +0000 Subject: [PATCH 2/3] affiliate: guard every outbound fetch against private hosts, and full CLI/MCP parity CodeQL flagged the four things in this feature that were mine: fetching a URL somebody typed (a merchant's descriptor, a profile, a join endpoint, a ledger), one regex-in-a-message line, and two cookie readers that built a RegExp from a variable. lib/affiliate/ssrf.ts now fronts every outbound request: http(s) on a default port only, no loopback / link-local / private names, every resolved address public, redirects followed by hand with each hop re-checked and never on a POST. The cookie readers split the header instead. CLI gains `affiliate webhook` and `affiliate token --yes`; MCP gains affiliate_set_webhook and affiliate_token, so every dashboard action has an API, CLI and MCP twin. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CDEiDss9RWYibtmxSk5Gr2 --- app/api/credits/create-invoice/route.ts | 4 +- app/auth/callback/route.ts | 6 +- cli/index.ts | 25 +++++- docs/affiliate.md | 2 + lib/affiliate/client.ts | 8 +- lib/affiliate/cookie.ts | 17 ++++ lib/affiliate/directory.ts | 2 +- lib/affiliate/ssrf.ts | 110 ++++++++++++++++++++++++ lib/mcp/affiliate.ts | 33 ++++++- tests/affiliate-ssrf.test.ts | 46 ++++++++++ 10 files changed, 241 insertions(+), 12 deletions(-) create mode 100644 lib/affiliate/ssrf.ts create mode 100644 tests/affiliate-ssrf.test.ts diff --git a/app/api/credits/create-invoice/route.ts b/app/api/credits/create-invoice/route.ts index 12fd9087..3e33f0a8 100644 --- a/app/api/credits/create-invoice/route.ts +++ b/app/api/credits/create-invoice/route.ts @@ -6,7 +6,7 @@ import { findPack } from "@/lib/credits"; import { createPayment } from "@/lib/coinpay"; import { env } from "@/lib/env"; import { attributeUser } from "@/lib/affiliate/attribution"; -import { COOKIE_NAME as OA_COOKIE } from "@/lib/affiliate/cookie"; +import { cookieFromHeader } from "@/lib/affiliate/cookie"; export const runtime = "nodejs"; @@ -42,7 +42,7 @@ export async function POST(req: Request) { // 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, req.headers.get("cookie")?.match(new RegExp(`(?:^|;\\s*)${OA_COOKIE}=([^;]+)`))?.[1] ?? null); + await attributeUser(user.id, cookieFromHeader(req.headers.get("cookie"))); } catch (err) { console.error("[affiliate] attribute at invoice", err); } diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts index 759ee0d0..763a0a1f 100644 --- a/app/auth/callback/route.ts +++ b/app/auth/callback/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; import { env } from "@/lib/env"; import { attributeUser } from "@/lib/affiliate/attribution"; -import { COOKIE_NAME as OA_COOKIE } from "@/lib/affiliate/cookie"; +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. @@ -36,12 +36,12 @@ export async function GET(request: Request) { } // OpenAffiliate: bind the signed-in user to the affiliate in their cookie. try { - const oa = request.headers.get("cookie")?.match(new RegExp(`(?:^|;\\s*)${OA_COOKIE}=([^;]+)`))?.[1] ?? null; + const oa = cookieFromHeader(request.headers.get("cookie")); if (oa) { const { data: { user }, } = await supabase.auth.getUser(); - if (user) await attributeUser(user.id, decodeURIComponent(oa)); + if (user) await attributeUser(user.id, oa); } } catch (err) { console.error("[affiliate] attribute at sign-in", err); diff --git a/cli/index.ts b/cli/index.ts index ad6d394d..f3cceafc 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -661,6 +661,23 @@ async function cmdAffiliate(args: Args): Promise { 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}`)); @@ -728,7 +745,7 @@ async function cmdAffiliate(args: Args): Promise { return 0; } - throw new Error(`unknown affiliate command: ${sub}. One of link, ledger, pay, payout, programs, join, joined.`); + throw new Error(`unknown affiliate command: ${sub}. One of link, ledger, pay, payout, webhook, token, programs, join, joined.`); } function help() { @@ -807,6 +824,12 @@ COMMANDS 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. diff --git a/docs/affiliate.md b/docs/affiliate.md index 3d0ef2cd..c5a5edd0 100644 --- a/docs/affiliate.md +++ b/docs/affiliate.md @@ -48,6 +48,8 @@ crawlproof affiliate [link] [--json] crawlproof affiliate ledger [--since=ISO] [--json] crawlproof affiliate pay --address 0x… crawlproof affiliate payout +crawlproof affiliate webhook | --clear +crawlproof affiliate token --yes crawlproof affiliate programs [add ] crawlproof affiliate join [--program=id] [--code=yours] crawlproof affiliate joined [--sync] diff --git a/lib/affiliate/client.ts b/lib/affiliate/client.ts index 35b608cc..51a5dd52 100644 --- a/lib/affiliate/client.ts +++ b/lib/affiliate/client.ts @@ -2,6 +2,7 @@ // program, read a ledger, and read an OpenProfile.md. Plain fetch with // timeouts and size caps; no Supabase here, so the CLI can use it directly. +import { safeFetch } from "./ssrf"; import { WELL_KNOWN_PATH, isVerifiedOrigin, @@ -18,10 +19,9 @@ const MAX_BYTES = 512 * 1024; async function fetchText(url: string, accept: string): Promise<{ ok: true; text: string; headers: Headers; url: string } | { ok: false; error: string; status?: number }> { try { - const res = await fetch(url, { + const res = await safeFetch(url, { headers: { accept, "user-agent": UA }, signal: AbortSignal.timeout(10_000), - redirect: "follow", }); if (!res.ok) return { ok: false, error: `HTTP ${res.status}`, status: res.status }; const buf = await res.arrayBuffer(); @@ -149,7 +149,7 @@ export async function joinProgram( if (program.status !== "active") return { ok: false, error: `${program.title} is ${program.status} and takes no joins.` }; let res: Response; try { - res = await fetch(program.join, { + res = await safeFetch(program.join, { method: "POST", headers: { "content-type": "application/json", accept: "application/json", "user-agent": UA }, body: JSON.stringify({ program: program.id, ...body }), @@ -187,7 +187,7 @@ export async function readLedger(ledgerUrl: string, token: string, since?: strin const u = new URL(ledgerUrl); if (since) u.searchParams.set("since", since); try { - const res = await fetch(u.toString(), { + const res = await safeFetch(u.toString(), { headers: { accept: "application/json", authorization: `Bearer ${token}`, "user-agent": UA }, signal: AbortSignal.timeout(15_000), }); diff --git a/lib/affiliate/cookie.ts b/lib/affiliate/cookie.ts index 9c3ee330..a6e1de7f 100644 --- a/lib/affiliate/cookie.ts +++ b/lib/affiliate/cookie.ts @@ -40,3 +40,20 @@ export function isNavigation(headers: { get(name: string): string | null }): boo const accept = headers.get("accept") ?? ""; return accept.includes("text/html"); } + +/** One cookie out of a Cookie header, without a regex. */ +export function cookieFromHeader(header: string | null | undefined, name = COOKIE_NAME): string | null { + if (!header) return null; + for (const part of header.split(";")) { + const eq = part.indexOf("="); + if (eq < 0) continue; + if (part.slice(0, eq).trim() !== name) continue; + const raw = part.slice(eq + 1).trim(); + try { + return decodeURIComponent(raw); + } catch { + return raw; + } + } + return null; +} diff --git a/lib/affiliate/directory.ts b/lib/affiliate/directory.ts index 6e6f1512..8b9a9127 100644 --- a/lib/affiliate/directory.ts +++ b/lib/affiliate/directory.ts @@ -219,7 +219,7 @@ export async function joinExternal( const program = input.programId ? row.descriptor.programs.find((p) => p.id === input.programId) : row.descriptor.programs.find((p) => p.status === "active") ?? row.descriptor.programs[0]; - if (!program) return { ok: false, error: `No program ${input.programId ?? ""} at ${row.origin}.`.replace(/\s+\./, ".") }; + if (!program) return { ok: false, error: input.programId ? `No program ${input.programId} at ${row.origin}.` : `No program at ${row.origin}.` }; const { data: twin } = await svc .from("affiliate_joins") diff --git a/lib/affiliate/ssrf.ts b/lib/affiliate/ssrf.ts new file mode 100644 index 00000000..2051797f --- /dev/null +++ b/lib/affiliate/ssrf.ts @@ -0,0 +1,110 @@ +// Fetching a URL somebody typed is the one thing this feature cannot avoid: +// a merchant's descriptor, an affiliate's profile, a join endpoint, a ledger. +// So every outbound request goes through here. A URL is fetched only when it +// is http(s) on a default port, its host is not a loopback, link-local or +// private name, and every address it resolves to is public. Redirects are +// followed by hand so each hop gets the same check. + +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +const BLOCKED_HOSTS = new Set(["localhost", "localhost.localdomain", "metadata.google.internal", "metadata"]); +const BLOCKED_SUFFIXES = [".localhost", ".local", ".internal", ".lan", ".home", ".arpa"]; + +/** True for any IPv4 or IPv6 address that must never be fetched. Pure, for tests. */ +export function isPrivateAddress(ip: string): boolean { + const v = isIP(ip); + if (v === 4) return isPrivateV4(ip); + if (v === 6) { + const lower = ip.toLowerCase(); + const mapped = lower.match(/^(?:0*:)*ffff:(\d+\.\d+\.\d+\.\d+)$/) ?? lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (mapped) return isPrivateV4(mapped[1]); + if (lower === "::" || lower === "::1") return true; + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // fc00::/7 + if (/^fe[89ab]/.test(lower)) return true; // fe80::/10 + if (lower.startsWith("::ffff:") || lower.startsWith("64:ff9b:")) return true; // v4-mapped / NAT64 + return false; + } + return true; // not an address at all +} + +function isPrivateV4(ip: string): boolean { + const parts = ip.split(".").map(Number); + if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true; + const [a, b] = parts; + if (a === 0 || a === 10 || a === 127) return true; + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + if (a === 169 && b === 254) return true; // link-local, cloud metadata + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 192 && b === 0) return true; // 192.0.0.0/24 and 192.0.2.0/24 + if (a === 198 && (b === 18 || b === 19)) return true; + if (a >= 224) return true; // multicast, reserved, broadcast + return false; +} + +/** Shape check with no network: scheme, port, host name. Pure, for tests. */ +export function checkUrlShape(input: string): { ok: true; url: URL } | { ok: false; error: string } { + let url: URL; + try { + url = new URL(input); + } catch { + return { ok: false, error: "not a URL" }; + } + if (url.protocol !== "https:" && url.protocol !== "http:") return { ok: false, error: "only http and https" }; + if (url.username || url.password) return { ok: false, error: "credentials in the URL" }; + if (url.port && url.port !== "80" && url.port !== "443") return { ok: false, error: "non-standard port" }; + const host = url.hostname.toLowerCase().replace(/\.$/, ""); + if (!host) return { ok: false, error: "no host" }; + const literal = host.startsWith("[") ? host.slice(1, -1) : host; + if (isIP(literal)) { + if (isPrivateAddress(literal)) return { ok: false, error: "private address" }; + return { ok: true, url }; + } + if (BLOCKED_HOSTS.has(host) || BLOCKED_SUFFIXES.some((s) => host.endsWith(s)) || !host.includes(".")) { + return { ok: false, error: "not a public host" }; + } + return { ok: true, url }; +} + +/** The full check: shape, then every resolved address. */ +export async function publicUrl(input: string): Promise<{ ok: true; url: URL } | { ok: false; error: string }> { + const shaped = checkUrlShape(input); + if (!shaped.ok) return shaped; + const host = shaped.url.hostname.replace(/^\[|\]$/g, ""); + if (isIP(host)) return shaped; + let addresses: Array<{ address: string }>; + try { + addresses = await lookup(host, { all: true, verbatim: true }); + } catch { + return { ok: false, error: `${host} does not resolve` }; + } + if (!addresses.length) return { ok: false, error: `${host} does not resolve` }; + if (addresses.some((a) => isPrivateAddress(a.address))) return { ok: false, error: `${host} resolves to a private address` }; + return shaped; +} + +const MAX_HOPS = 3; + +/** + * fetch() for URLs we did not choose. Redirects are followed by hand, each + * hop re-checked, so a public host cannot bounce us onto a private one. + */ +export async function safeFetch(input: string, init: RequestInit = {}): Promise { + let current = input; + for (let hop = 0; hop <= MAX_HOPS; hop++) { + const checked = await publicUrl(current); + if (!checked.ok) throw new Error(`refused ${current}: ${checked.error}`); + const res = await fetch(checked.url.toString(), { ...init, redirect: "manual" }); + if (res.status >= 300 && res.status < 400) { + const location = res.headers.get("location"); + if (!location || hop === MAX_HOPS) throw new Error(`too many redirects from ${input}`); + current = new URL(location, checked.url).toString(); + // A redirect turns a POST into a GET for 301/302/303; we never follow one on a POST. + if (init.method && init.method !== "GET") throw new Error(`refused a redirect on ${init.method} to ${current}`); + continue; + } + return res; + } + throw new Error(`too many redirects from ${input}`); +} diff --git a/lib/mcp/affiliate.ts b/lib/mcp/affiliate.ts index 61728c8a..e3363853 100644 --- a/lib/mcp/affiliate.ts +++ b/lib/mcp/affiliate.ts @@ -6,7 +6,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { serviceClient } from "@/lib/supabase/service"; -import { ensureMembershipForUser, ledgerFor, profileUrlForMembership, setPayAddress } from "@/lib/affiliate/memberships"; +import { ensureMembershipForUser, ledgerFor, profileUrlForMembership, rotateToken, setPayAddress, setWebhook } from "@/lib/affiliate/memberships"; import { addOrRefreshProgram, joinExternal, listDirectory, listJoins, syncJoin } from "@/lib/affiliate/directory"; import { requestPayout } from "@/lib/affiliate/payouts"; import { termsLine } from "@/lib/affiliate/program"; @@ -84,6 +84,37 @@ export function registerAffiliateTools(server: McpServer): void { }, ); + server.registerTool( + "affiliate_set_webhook", + { + description: "Set (or clear) the https URL the caller's affiliate events are POSTed to: conversion.recorded/approved/reversed, payout.sent, program.changed. Signed with Ed25519 when the merchant key is set; the ledger is the truth either way.", + inputSchema: { url: z.string().describe("https URL, or empty to clear") }, + }, + async (args, extra) => { + const m = await ensureMembershipForUser(await userOf(getUserId(extra))); + if (!m) return textResult("No membership."); + const u = args.url.trim(); + if (u && !/^https:\/\//.test(u)) return textResult("A webhook is an https URL."); + await setWebhook(m.id, u || null); + return textResult(u ? `Webhook set to ${u}.` : "Webhook cleared."); + }, + ); + + server.registerTool( + "affiliate_token", + { + description: "Issue a new oa_ affiliate token for the caller (shown once; the old one stops working at once). It reads only the ledger at /api/affiliate/v1/ledger, which is what to hand a third party.", + inputSchema: { confirm: z.boolean().describe("true to confirm the rotation") }, + }, + async (args, extra) => { + if (!args.confirm) return textResult("Not rotated. Pass confirm=true; the current token stops working the moment a new one is issued."); + const m = await ensureMembershipForUser(await userOf(getUserId(extra))); + if (!m) return textResult("No membership."); + const token = await rotateToken(m.id); + return textResult(`New affiliate token (shown once): ${token}`); + }, + ); + server.registerTool( "affiliate_payout", { description: "Send the caller's approved affiliate balance to their payout address now, if it is over the program minimum.", inputSchema: {} }, diff --git a/tests/affiliate-ssrf.test.ts b/tests/affiliate-ssrf.test.ts new file mode 100644 index 00000000..eef8d4d1 --- /dev/null +++ b/tests/affiliate-ssrf.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { checkUrlShape, isPrivateAddress } from "@/lib/affiliate/ssrf"; +import { cookieFromHeader } from "@/lib/affiliate/cookie"; + +describe("isPrivateAddress", () => { + it("blocks every range a merchant URL must never reach", () => { + for (const ip of ["127.0.0.1", "10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.1.1", "169.254.169.254", "0.0.0.0", "100.64.0.1", "::1", "::", "fd12::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:127.0.0.1", "224.0.0.1", "255.255.255.255"]) { + expect(isPrivateAddress(ip), ip).toBe(true); + } + }); + it("allows public addresses", () => { + for (const ip of ["8.8.8.8", "1.1.1.1", "172.32.0.1", "104.18.0.1", "2606:4700::1111"]) expect(isPrivateAddress(ip), ip).toBe(false); + }); + it("treats a non-address as private", () => { + expect(isPrivateAddress("nope")).toBe(true); + }); +}); + +describe("checkUrlShape", () => { + it("accepts a public https origin", () => { + expect(checkUrlShape("https://nichedb.dev/.well-known/openaffiliate.json")).toMatchObject({ ok: true }); + expect(checkUrlShape("http://example.com:80/")).toMatchObject({ ok: true }); + }); + it("refuses what cannot be a merchant", () => { + expect(checkUrlShape("ftp://example.com")).toMatchObject({ ok: false }); + expect(checkUrlShape("https://user:pw@example.com")).toMatchObject({ ok: false, error: "credentials in the URL" }); + expect(checkUrlShape("https://example.com:8443/")).toMatchObject({ ok: false, error: "non-standard port" }); + expect(checkUrlShape("https://localhost/")).toMatchObject({ ok: false }); + expect(checkUrlShape("https://db.internal/")).toMatchObject({ ok: false }); + expect(checkUrlShape("https://railway/")).toMatchObject({ ok: false }); + expect(checkUrlShape("https://127.0.0.1/")).toMatchObject({ ok: false, error: "private address" }); + expect(checkUrlShape("https://[::1]/")).toMatchObject({ ok: false, error: "private address" }); + expect(checkUrlShape("https://169.254.169.254/latest/meta-data")).toMatchObject({ ok: false }); + expect(checkUrlShape("not a url")).toMatchObject({ ok: false }); + }); +}); + +describe("cookieFromHeader", () => { + it("finds the cookie among others and decodes it", () => { + expect(cookieFromHeader("a=1; oa=anthony.1789300800; b=2")).toBe("anthony.1789300800"); + expect(cookieFromHeader("oa=anthony.1789300800")).toBe("anthony.1789300800"); + expect(cookieFromHeader("oa=a%2Eb")).toBe("a.b"); + expect(cookieFromHeader("oab=x; xoa=y")).toBeNull(); + expect(cookieFromHeader(null)).toBeNull(); + }); +}); From 12163b106d76b9084f9d7064446222714e19e5f0 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 02:55:52 +0000 Subject: [PATCH 3/3] affiliate: judge an IPv4-mapped IPv6 address without a nested-quantifier regex Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CDEiDss9RWYibtmxSk5Gr2 --- lib/affiliate/ssrf.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/affiliate/ssrf.ts b/lib/affiliate/ssrf.ts index 2051797f..5e3581cd 100644 --- a/lib/affiliate/ssrf.ts +++ b/lib/affiliate/ssrf.ts @@ -17,8 +17,9 @@ export function isPrivateAddress(ip: string): boolean { if (v === 4) return isPrivateV4(ip); if (v === 6) { const lower = ip.toLowerCase(); - const mapped = lower.match(/^(?:0*:)*ffff:(\d+\.\d+\.\d+\.\d+)$/) ?? lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); - if (mapped) return isPrivateV4(mapped[1]); + // IPv4-mapped (::ffff:a.b.c.d): judge the IPv4 part. + const ffff = lower.lastIndexOf("ffff:"); + if (ffff >= 0 && lower.slice(ffff + 5).includes(".")) return isPrivateV4(lower.slice(ffff + 5)); if (lower === "::" || lower === "::1") return true; if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // fc00::/7 if (/^fe[89ab]/.test(lower)) return true; // fe80::/10