From 191d1445a5be1278b45e472af33fef47e4f64290 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 27 Oct 2026 03:21:26 +0000 Subject: [PATCH] feat: credit codes + invites (ad credit, spend-only through crawlproof grants) Credit codes (@profullstack/redeem-credits 0.2.0, unit usd_cents) and scarce invites (@profullstack/affiliates 0.3.0, 3 each, one accepted per account). Every grant goes to crawlproof first (POST /api/ads/v1/grants: ad_bonus_credits on the shared advertiser account, runs ads, pays no publisher), then a deposits row (coin 'credit') adds it to the person's balance. Both idempotent on the source. /me "Have a code?", /invites, /i/ (accepted at CoinPay sign-in), API, CLI (redeem, invites, invite, accept), MCP. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 ++ bun.lock | 11 +++ package.json | 5 +- src/cli.ts | 25 ++++++ src/client.ts | 13 +++ src/crawlproof.ts | 12 +++ src/html.ts | 61 ++++++++++++- src/mcp.ts | 3 + src/rewards.ts | 214 ++++++++++++++++++++++++++++++++++++++++++++++ src/server.ts | 146 ++++++++++++++++++++++++++++++- src/service.ts | 1 + src/version.ts | 2 +- test/app.test.ts | 129 +++++++++++++++++++++++++++- 13 files changed, 620 insertions(+), 10 deletions(-) create mode 100644 src/rewards.ts diff --git a/README.md b/README.md index 88accff..367a094 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,14 @@ Deposits buy ad budget at face value ($1 = $1 of clicks) through crawlproof's partner deposit API. A site is yours once we see your tag on it: every tag carries a claim code for one person, and withdrawals wait for it. +## Credit codes and invites + +- **Credit codes** (`@profullstack/redeem-credits`, unit `usd_cents`) are ad credit. Mint on dev2: + `cd /opt/ad.network && bun node_modules/@profullstack/redeem-credits/dist/cli.js mint --db /root/.local/share/ad-network/ad-network.db --unit usd_cents --amount 1000 --prefix AD --label "for X" [--max N] [--expires 90d]` + (also `list`, `revoke `). Redeem at `/me` ("Have a code?"), `POST /api/v1/credits/redeem`, `adnetwork redeem`, MCP `redeem_credit_code`. +- **Invites** (`@profullstack/affiliates`): 3 per account, `/invites`, `/i/` (cookie `an_invite`, accepted at the CoinPay sign-in, or paste the code on `/invites`). One accepted invite per account, accounts under 7 days old; senders must be a day old. Both sides get `ADNETWORK_INVITE_BONUS_CENTS` (default 500) of ad credit, except throwaway addresses, the inviter's own mailbox (+tag / Gmail dots), house accounts; the inviter's half stops at `ADNETWORK_INVITE_MONTHLY_CAP` (5) per 30 days. +- **Where ad credit lives.** Every grant goes to crawlproof first (`POST /api/ads/v1/grants`): it lands on the shared advertiser account as `ad_bonus_credits`, which runs ads but pays no publisher, so credit nobody paid for can never be withdrawn as crypto. Then a `deposits` row (coin `credit`, id `credit:`) adds it to the person's balance here. Both are idempotent on the source. + ## Run it ```sh diff --git a/bun.lock b/bun.lock index 07235f6..2aa8aa9 100644 --- a/bun.lock +++ b/bun.lock @@ -6,10 +6,13 @@ "name": "@profullstack/adnetwork", "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", + "@profullstack/affiliates": "0.3.0", "@profullstack/coinpay-onboard": "^0.2.0", "@profullstack/footer": "^0.4.0", "@profullstack/hqtui": "^0.11.0", + "@profullstack/redeem-credits": "0.2.0", "hono": "^4.13.14", + "mailchecker": "^6.0.21", "qrcode": "^1.5.4", "zod": "^4.6.7", }, @@ -28,12 +31,18 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.32.2", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw=="], + "@profullstack/affiliates": ["@profullstack/affiliates@0.3.0", "", { "dependencies": { "@profullstack/referrals": "^0.1.0" }, "bin": { "affiliates": "dist/cli.js" } }, "sha512-bp0iREciWetDWrQYOsa6SJRTzviekKwgJQuATT7vql+4equu7pN76q8tb3SP1oBMczydsv6rYUMvPpS6+QT7yQ=="], + "@profullstack/coinpay-onboard": ["@profullstack/coinpay-onboard@0.2.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.17.0", "zod": "^3.25.0" }, "bin": { "coinpay-onboard": "bin/coinpay-onboard" } }, "sha512-FxIiTefcAswyCyEapEcC+vSai3IZcOPPxaBGnT7L/VPGfZJYjH1kHoN6DCRMBQx0l2IbJ2aDLqrYUhqRUaI2cA=="], "@profullstack/footer": ["@profullstack/footer@0.4.0", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"], "bin": { "profullstack-footer": "bin/profullstack-footer.js" } }, "sha512-EJZnMUQmWDi9Gzx6BAjogloeaPFFSSCHtHJuC9dsXiOz8M87hfn6Z+Zx9AMYWk8s6xemPrPlkXKGB3PZugkcEA=="], "@profullstack/hqtui": ["@profullstack/hqtui@0.11.0", "", { "bin": { "hqtui": "bin/hqtui.mjs" } }, "sha512-kvpruktJsxwcQ8VYxdxDJn6dtPoWOMuOtr87DZbVV0KIrm7BBF5HRf37gA5Am/ReWd/ayEn9rvmHN6rVFTFALA=="], + "@profullstack/redeem-credits": ["@profullstack/redeem-credits@0.2.0", "", { "bin": { "redeem-credits": "dist/cli.js" } }, "sha512-JLowb7mWYfouIK7O522tuPPhvLjQjhdEfm6g3xEYrlNLGzLhrIADDhrYldBlGHfiVBWUFX7wefLuCSLF9fnyog=="], + + "@profullstack/referrals": ["@profullstack/referrals@0.1.0", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"] }, "sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg=="], + "@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="], "@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], @@ -206,6 +215,8 @@ "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "mailchecker": ["mailchecker@6.0.21", "", {}, "sha512-Zd2Lgj4RMnC0IBbbSjnF76Wbd+kkgKMRONgV9yOMKJhj6hcKIAQtj9MrmiGnT2Db+Dy6MsMQoG2HEhPk2YPuGA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], diff --git a/package.json b/package.json index 88b6c10..f3d9967 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/adnetwork", - "version": "0.1.8", + "version": "0.2.0", "description": "ad.network: run ads, or earn from your site, in crypto. Paste a link and a budget, or paste one tag and add a wallet. Web, API, CLI, TUI and MCP; ads by crawlproof, payments by CoinPay.", "license": "MIT", "type": "module", @@ -42,10 +42,13 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", + "@profullstack/affiliates": "0.3.0", "@profullstack/coinpay-onboard": "^0.2.0", "@profullstack/footer": "^0.4.0", "@profullstack/hqtui": "^0.11.0", + "@profullstack/redeem-credits": "0.2.0", "hono": "^4.13.14", + "mailchecker": "^6.0.21", "qrcode": "^1.5.4", "zod": "^4.6.7" }, diff --git a/src/cli.ts b/src/cli.ts index 8eccf31..aa24e29 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -27,6 +27,11 @@ export const HELP = `adnetwork ${VERSION}: run ads, or earn from your site, in c adnetwork withdraw adnetwork remove + adnetwork redeem redeem a credit code for ad credit + adnetwork invites your invites (scarce: 3 each) + adnetwork invite [note] make an invite link + adnetwork accept accept an invite on your new account + adnetwork mcp MCP server: claude mcp add adnetwork -- bunx @profullstack/adnetwork mcp adnetwork serve [--port N] run your own instance @@ -157,6 +162,26 @@ export async function main(argv = process.argv.slice(2), out: Out = console, cli show(r, `${money(r.amount_usd)} ${r.status === 'requested' ? 'queued' : 'sent'}.`) return 0 } + case 'redeem': { + const r = await client.redeem(need(rest[0], 'a credit code')) + show(r, `${money(r.credit_usd)} of ad credit added. Balance ${money(r.balance_usd)}.`) + return 0 + } + case 'invites': { + const r = await client.invites() + show(r, `${r.quota.left} of ${r.quota.total} invites left${r.can_invite ? '' : ` (${r.why_not})`}. Each accepted invite: ${money(r.bonus_usd)} of ad credit for both of you.\n${r.invites.map((i) => ` ${i.used ? 'used' : 'open'} ${i.created_at.slice(0, 10)} ${i.note || 'invite'}`).join('\n') || ' none yet: adnetwork invite'}`) + return 0 + } + case 'invite': { + const r = await client.createInvite(rest.join(' ') || undefined) + show(r, `${r.link}\nShown once. ${r.left} left.`) + return 0 + } + case 'accept': { + const r = await client.acceptInvite(need(rest[0], 'an invite code')) + show(r, r.bonus_usd ? `Accepted: ${money(r.bonus_usd)} of ad credit added.` : `Accepted${r.no_bonus ? ` (no bonus: ${r.no_bonus})` : ''}.`) + return 0 + } case 'remove': { show(await client.removeSite(need(rest[0], 'a site')), 'Ads stopped.') return 0 diff --git a/src/client.ts b/src/client.ts index 00fa4e0..3817a55 100644 --- a/src/client.ts +++ b/src/client.ts @@ -78,6 +78,19 @@ export class AdNetworkClient { withdraw(site: string): Promise<{ amount_usd: number; status: string }> { return this.call('POST', `/api/v1/sites/${encodeURIComponent(site)}/withdraw`) } + /** Redeem a credit code: ad credit on your balance (spend-only, not withdrawable). */ + redeem(code: string): Promise<{ ok: boolean; credit_usd: number; balance_usd: number }> { + return this.call('POST', '/api/v1/credits/redeem', { code }) + } + invites(): Promise<{ quota: { total: number; used: number; left: number }; bonus_usd: number; can_invite: boolean; why_not?: string; invites: { id: string; note: string; created_at: string; expires_at: string | null; used: boolean }[] }> { + return this.call('GET', '/api/v1/invites') + } + createInvite(note?: string): Promise<{ code: string; link: string; expires_at: string | null; left: number }> { + return this.call('POST', '/api/v1/invites', note ? { note } : {}) + } + acceptInvite(code: string): Promise<{ ok: boolean; bonus_usd: number; no_bonus?: string }> { + return this.call('POST', '/api/v1/invites/accept', { code }) + } removeSite(site: string): Promise<{ ok: boolean }> { return this.call('DELETE', `/api/v1/sites/${encodeURIComponent(site)}`) } diff --git a/src/crawlproof.ts b/src/crawlproof.ts index ba9d50d..00d2158 100644 --- a/src/crawlproof.ts +++ b/src/crawlproof.ts @@ -73,6 +73,9 @@ export type SlotMoney = { min_payout_cents: number } +/** A spend-only ad credit grant (POST /api/ads/v1/grants). */ +export type Grant = { reference: string; applied: boolean; credits: number; amount_usd: number; ad_bonus_credits: number; spend_only: boolean } + export class CrawlproofError extends Error { constructor(message: string, readonly status: number) { super(message) @@ -122,6 +125,15 @@ export class Crawlproof { return this.call('POST', '/api/ads/v1/deposits', input) } + /** + * Spend-only ad credit on our advertiser account (crawlproof ad_bonus_credits): + * runs ads like a deposit, never pays a publisher, never withdrawable. + * Idempotent on `reference`: a repeat answers applied:false and grants nothing. + */ + grant(input: { amount_usd: number; reference: string }): Promise { + return this.call('POST', '/api/ads/v1/grants', input) + } + depositStatus(paymentId: string): Promise { return this.call('GET', `/api/ads/v1/deposits/${encodeURIComponent(paymentId)}`) } diff --git a/src/html.ts b/src/html.ts index 907dc6a..4b307be 100644 --- a/src/html.ts +++ b/src/html.ts @@ -137,6 +137,7 @@ export function helpPage(ctx: Ctx): string {
  • Add a wallet for USDC (Polygon, Solana, Base or Ethereum).
  • You earn about 5¢ of every ${usd(CLICK_CENTS)} paid click on your site. Withdraw any time you have ${usd(MIN_PAYOUT_CENTS)} or more; it goes straight from crawlproof to your wallet.
  • Someone else claimed your site? If they never placed their tag, add <meta name="ad.network" content="your code"> (the code is shown when you try) and claim it again.

    +

    Credit codes and invites

    A credit code adds ad credit to your balance (redeem it). Each account has three invites; when someone new joins with yours, you both get ad credit. Ad credit runs your ads like budget you paid for, but it is not money: it cannot be withdrawn or refunded, and clicks bought with it pay publishers nothing.

    Fees

    ad.network adds none. crawlproof keeps 30% of the publisher side of each click; the network fee for sending crypto is the sender's, as always.

    Agents: API, CLI, MCP

    Make a key on your page, then:

    @@ -153,6 +154,10 @@ POST /api/v1/sites/:site/check look for the tag now PATCH /api/v1/sites/:site {"wallet": "0x…", "coin": "usdc_pol"} POST /api/v1/sites/:site/withdraw DELETE /api/v1/sites/:site +POST /api/v1/credits/redeem {"code": "AD-…"} -> ad credit (spend-only) +GET /api/v1/invites your invites, 3 each +POST /api/v1/invites {"note": "for Sam"} -> an invite link +POST /api/v1/invites/accept {"code": "INV-…"} (new accounts) -H 'authorization: Bearer an_…'
    curl -fsSL ${SITE}/install.sh | sh          installs the adnetwork CLI (needs bun)
    @@ -318,7 +323,14 @@ ${s.verifiedAt ? `

    Seen on https://${esc(s.host)}/ ${esc(when }) } -export function mePage(ctx: Ctx, o: { user: User; balanceCents: number; apiKey?: string; flash?: string }): string { +/** "Have a code?": redeem a credit code for ad credit. */ +function redeemCard(ctx: Ctx, code = ''): string { + return `

    ${csrfField(ctx)}

    Have a code?

    +

    A credit code adds ad credit to your balance. Ad credit runs your ads like budget you paid for; it cannot be withdrawn.

    +
    ` +} + +export function mePage(ctx: Ctx, o: { user: User; balanceCents: number; apiKey?: string; flash?: string; error?: string }): string { const u = o.user return layout({ ctx, @@ -328,8 +340,10 @@ export function mePage(ctx: Ctx, o: { user: User; balanceCents: number; apiKey?: description: 'Your ad.network account.', path: '/me', body: `

    Hi${u.name ? `, ${esc(u.name.split(' ')[0])}` : ''}.

    Signed in with CoinPay as ${esc(u.email || u.name)}.

    -${flashNote(o.flash)} +${flashNote(o.flash)}${flashNote(o.error, true)} +${redeemCard(ctx)} +

    Invites

    Hand out up to three invites. When someone new joins with one, you both get ad credit. Your invites

    API key

    ${o.apiKey ? `

    Your key, shown once:

    ${esc(o.apiKey)}

    export ADNETWORK_KEY=${esc(o.apiKey)}

    ` : `

    For the API, the CLI and MCP. ${u.apiKeyHash ? 'You have one; making a new one replaces it.' : ''}

    `}
    ${csrfField(ctx)}
    Delete my account
    ${csrfField(ctx)}

    Withdraw your earnings first. Unspent ad budget is not refunded automatically: ask on GitHub.

    `, @@ -385,3 +399,46 @@ echo " export ADNETWORK_KEY=an_..." echo " adnetwork" ` } + +export type InviteView = { id: string; note: string; createdAt: number; expiresAt: number | null; usedBy: string | null; usedAt: number | null; revokedAt?: number | null } + +/** /invites: your scarce invites, an invite code to accept, and a credit code to redeem. */ +export function invitesPage( + ctx: Ctx, + o: { quota: { total: number; used: number; left: number }; invites: InviteView[]; bonusCents: number; canInvite: string | null; myBonus: { paid: boolean; reason: string | null } | null; link?: string; flash?: string; error?: string; code?: string }, +): string { + const now = Date.now() + const state = (i: InviteView) => (i.usedBy ? `used ${when(i.usedAt ?? now)}` : i.revokedAt ? 'withdrawn' : i.expiresAt && i.expiresAt < now ? 'expired' : `open until ${when(i.expiresAt ?? now).slice(0, 10)}`) + return layout({ + ctx, + noindex: true, + script: true, + title: 'Invites · ad.network', + description: 'Your ad.network invites and credit codes.', + path: '/invites', + body: `

    Invites

    Invites are scarce on purpose: ${o.quota.total} each, one use, 30 days. When someone new joins with yours, you both get ${usd(o.bonusCents)} of ad credit.

    +${flashNote(o.flash)}${flashNote(o.error, true)} +

    Your invites ${o.quota.left} of ${o.quota.total} left

    +${o.link ? `

    Your new invite link, shown once:

    ${esc(o.link)}
    ` : ''} +${o.canInvite ? `

    ${esc(o.canInvite)}

    ` : o.quota.left > 0 ? `
    ${csrfField(ctx)}
    ` : '

    No invites left.

    '} +${o.invites.length ? `${o.invites.map((i) => ``).join('')}
    ForMadeStatus
    ${esc(i.note || 'invite')}${when(i.createdAt).slice(0, 10)}${esc(state(i))}
    ` : '

    None yet.

    '}
    +

    Have an invite code?

    ${o.myBonus ? `

    You joined with an invite${o.myBonus.paid ? ' and the ad credit is on your balance.' : '.'}

    ` : `

    New accounts (under a week old) can accept one invite.

    ${csrfField(ctx)}
    `}
    +${redeemCard(ctx)}`, + }) +} + +/** /i/: someone sent you an invite. */ +export function inviteLandingPage(ctx: Ctx, o: { code: string | null; bonusCents: number }): string { + return layout({ + ctx, + noindex: true, + title: "You're invited · ad.network", + description: 'Someone invited you to ad.network: run ads, or earn from your site, in crypto.', + path: '/', + body: o.code + ? `

    You're invited

    Someone on ad.network wants you in. Sign in with CoinPay and you both get ${usd(o.bonusCents)} of ad credit to run your first ads.

    +

    ${ctx.user ? 'Accept on my account' : 'Sign in with CoinPay'}

    +

    Invite ${esc(o.code)}. For new accounts, one per account. On another device? Paste the code at /invites.

    ` + : `

    Not an invite

    That invite link is not right. Ask for a fresh one.

    Home
    `, + }) +} diff --git a/src/mcp.ts b/src/mcp.ts index 70f5377..edaadc9 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -49,6 +49,9 @@ export function createMcpServer(client = new AdNetworkClient()): McpServer { { site, wallet: z.string().min(20), coin: z.enum(['usdc_pol', 'usdc_sol', 'usdc_base', 'usdc_eth']).default('usdc_pol') }, wrap(({ site, wallet, coin }) => client.setWallet(site, wallet, coin)), ) + server.tool('redeem_credit_code', 'Redeem an ad.network credit code: adds ad credit to your balance (runs ads, cannot be withdrawn).', { code: z.string().min(6).max(64) }, wrap(({ code }) => client.redeem(code))) + server.tool('list_invites', 'Your invites: how many are left (3 each) and which were used.', {}, wrap(() => client.invites())) + server.tool('create_invite', 'Make an invite link to hand to one new person; when they join, you both get ad credit.', { note: z.string().max(80).optional() }, wrap(({ note }) => client.createInvite(note))) server.tool('withdraw', "Send a site's earnings to its wallet (minimum $5).", { site }, wrap(({ site }) => client.withdraw(site))) return server } diff --git a/src/rewards.ts b/src/rewards.ts new file mode 100644 index 0000000..8a5de94 --- /dev/null +++ b/src/rewards.ts @@ -0,0 +1,214 @@ +/** + * Credit codes and scarce invites for ad.network. + * + * - A credit code (@profullstack/redeem-credits, unit `usd_cents`) is ad credit. + * - Each person can hand out INVITES_PER_PERSON invites (@profullstack/affiliates); + * one accepted invite per account, and both people get INVITE_BONUS_CENTS of ad credit. + * + * Ad credit goes through the same two books a deposit does, in this order: + * 1. crawlproof: POST /api/ads/v1/grants puts it on our shared advertiser + * account as ad_bonus_credits. That credit runs ads like a deposit but pays + * no publisher, so credit nobody paid for can never leave as crypto. + * 2. here: a `deposits` row (coin 'credit', status complete, id credit:) + * adds it to the person's own balance, which is what decides whose ads run. + * Both writes are idempotent on the source, so a retry or a repair never pays twice, + * and the person's balance only moves once crawlproof holds the credit. + */ +import { createRequire } from 'node:module' +import { Affiliates, sqliteStore as affiliateStore } from '@profullstack/affiliates' +import { Credits, sqliteStore as creditStore } from '@profullstack/redeem-credits' +import type { Store, User } from './db.js' +import { UserError, type AdNetwork } from './service.js' + +const require_ = createRequire(import.meta.url) + +export const CREDIT_UNIT = 'usd_cents' +export const CREDIT_COIN = 'credit' +export const INVITES_PER_PERSON = 3 +const DAY_MS = 86_400_000 + +export type RewardsConfig = { + /** Ad credit each side of an accepted invite gets, in cents. */ + inviteBonusCents?: number + /** Paid inviter bonuses per inviter per 30 days. */ + inviteMonthlyCap?: number + /** An account must be this old to send invites. */ + inviterMinAgeMs?: number + /** Only accounts younger than this can accept an invite. */ + inviteeMaxAgeMs?: number + now?: () => number +} + +export const REDEEM_MESSAGES: Record = { + invalid: 'That code is not valid.', + expired: 'That code has expired.', + used_up: 'That code has been used up.', + already_redeemed: 'You already redeemed that code.', + revoked: 'That code was withdrawn.', +} + +export const ACCEPT_MESSAGES: Record = { + invalid: 'That invite code is not valid.', + expired: 'That invite has expired.', + used: 'That invite was already used.', + self: 'You cannot use your own invite.', + revoked: 'That invite was withdrawn.', + already_invited: 'Your account has already accepted an invite.', + too_old: 'Invites are for new accounts.', +} + +/** One mailbox, however it is spelled: lowercased, +tag dropped, Gmail dots ignored. */ +export function mailboxKey(email: string): string { + const [local0, domain0] = String(email || '').trim().toLowerCase().split('@') + if (!local0 || !domain0) return String(email || '').trim().toLowerCase() + const domain = domain0 === 'googlemail.com' ? 'gmail.com' : domain0 + let local = local0.split('+')[0]! + if (domain === 'gmail.com') local = local.replace(/\./g, '') + return `${local}@${domain}` +} + +export function isThrowawayEmail(email: string): boolean { + try { + return !require_('mailchecker').isValid(String(email || '')) + } catch { + return false + } +} + +export type AcceptResult = { ok: true; paid: boolean; reason?: string; bonusCents: number } | { ok: false; reason: string } + +export class Rewards { + readonly credits: Credits + readonly affiliates: Affiliates + private readonly now: () => number + + constructor( + readonly store: Store, + readonly net: AdNetwork, + readonly config: RewardsConfig = {}, + ) { + this.now = config.now ?? Date.now + // Same SQLite file as the rest of ad.network: tables credit_* and aff_*. + this.credits = new Credits(creditStore(store.db as never), { + onGrant: async (sub, grant, source) => { + if (grant.unit !== CREDIT_UNIT) throw new Error(`unsupported credit unit ${grant.unit}`) + await this.grantAdCredit(sub, grant.amount, source) + }, + }) + this.affiliates = new Affiliates(affiliateStore(store.db as never), { defaultQuota: INVITES_PER_PERSON }) + store.db.exec(`CREATE TABLE IF NOT EXISTS invite_bonuses ( + invitee TEXT PRIMARY KEY, + inviter TEXT NOT NULL, + invite_id TEXT NOT NULL, + paid INTEGER NOT NULL, + reason TEXT, + created_at INTEGER NOT NULL + )`) + } + + get inviteBonusCents() { + return this.config.inviteBonusCents ?? 500 + } + + /** + * Ad credit for one person, once per source: on crawlproof first (spend-only), + * then on their balance here. False if this source was already granted. + */ + async grantAdCredit(sub: string, cents: number, source: string): Promise { + const id = `credit:${source}` + if (this.store.deposit(id)) return false + const amount = Math.floor(Number(cents) / 5) * 5 // crawlproof credits are 5c each + if (!sub || !source || !(amount > 0)) throw new Error('grantAdCredit: bad grant') + const g = await this.net.ads.grant({ amount_usd: amount / 100, reference: `ad.network:${sub}:${source}` }) + const granted = Math.round(g.amount_usd * 100) + this.store.addDeposit({ id, sub, amountCents: granted, coin: CREDIT_COIN, address: '', amountCrypto: '', status: 'complete', expiresAt: null }) + await this.net.enforce(sub).catch(() => {}) + return true + } + + /** Redeems a code for ad credit. A grant that did not land last time is finished on retry. */ + async redeem(user: User, code: string): Promise<{ cents: number }> { + const r = await this.credits.redeem(String(code ?? ''), user.sub) + if (!('reason' in r)) return { cents: r.grant.amount } + if (r.reason === 'already_redeemed') await this.repair(user.sub).catch(() => {}) + throw new UserError(REDEEM_MESSAGES[r.reason] ?? 'That code did not work.', 'code') + } + + /** Replays every code grant in the module's ledger; the ones already here are skipped. */ + async repair(sub: string): Promise { + for (const h of await this.credits.history(sub, CREDIT_UNIT)) { + if (h.kind === 'grant' && h.source.startsWith('code:')) await this.grantAdCredit(sub, h.amount, h.source) + } + } + + private ageMs(user: User): number { + return this.now() - user.createdAt + } + + canInvite(user: User): { ok: true } | { ok: false; error: string } { + const min = this.config.inviterMinAgeMs ?? DAY_MS + if (this.ageMs(user) < min) return { ok: false, error: `Invites open once your account is ${Math.round(min / 3_600_000)} hours old.` } + return { ok: true } + } + + async createInvite(user: User, note = ''): Promise<{ code: string; expiresAt: number | null; left: number }> { + const check = this.canInvite(user) + if ('error' in check) throw new UserError(check.error, undefined, 403) + try { + const invite = await this.affiliates.createInvite(user.sub, String(note).slice(0, 80)) + return { code: invite.code, expiresAt: invite.expiresAt, left: (await this.affiliates.quota(user.sub)).left } + } catch (e) { + if (e instanceof Error && /no invites left/i.test(e.message)) throw new UserError('You have no invites left.', undefined, 403) + throw e + } + } + + /** + * Accepts an invite for a new account. The invite is spent only by + * redeemInvite() (one per account, enforced in the database), and the bonus + * is decided only after it answers ok. No bonus for a throwaway address, the + * inviter's own mailbox, a house account, or an account with no email; the + * inviter's half stops at the monthly cap. + */ + async acceptInvite(user: User, code: string): Promise { + if (this.ageMs(user) > (this.config.inviteeMaxAgeMs ?? 7 * DAY_MS)) return { ok: false, reason: 'too_old' } + const r = await this.affiliates.redeemInvite(String(code ?? ''), user.sub) + if ('reason' in r) return { ok: false, reason: r.reason } + + const inviter = this.store.user(r.inviterId) + const house = (sub: string) => this.net.config.houseSubs?.includes(sub) ?? false + let denied: string | null = null + if (!inviter) denied = 'inviter_gone' + else if (!user.email) denied = 'no_email' + else if (house(user.sub) || house(inviter.sub)) denied = 'house' + else if (isThrowawayEmail(user.email)) denied = 'throwaway_email' + else if (inviter.email && mailboxKey(inviter.email) === mailboxKey(user.email)) denied = 'same_mailbox' + + const claimed = this.store.db + .query('INSERT OR IGNORE INTO invite_bonuses (invitee, inviter, invite_id, paid, reason, created_at) VALUES (?, ?, ?, ?, ?, ?)') + .run(user.sub, r.inviterId, r.inviteId, denied ? 0 : 1, denied, this.now()) + if (denied || claimed.changes === 0) return { ok: true, paid: false, ...(denied ? { reason: denied } : {}), bonusCents: 0 } + + const bonus = this.inviteBonusCents + await this.grantAdCredit(user.sub, bonus, `invite:${r.inviteId}:invitee`) + const recent = (this.store.db + .query("SELECT count(*) AS n FROM deposits WHERE sub = ? AND coin = 'credit' AND id LIKE 'credit:invite:%:inviter' AND created_at > ?") + .get(r.inviterId, this.now() - 30 * DAY_MS) as { n: number }).n + if (recent < (this.config.inviteMonthlyCap ?? 5)) await this.grantAdCredit(r.inviterId, bonus, `invite:${r.inviteId}:inviter`) + else this.store.db.query("UPDATE invite_bonuses SET reason = 'inviter_monthly_cap' WHERE invitee = ?").run(user.sub) + return { ok: true, paid: true, bonusCents: bonus } + } + + /** Finishes an invite bonus that did not land (crawlproof was down at accept time). */ + async repairInvite(invitee: string): Promise { + const row = this.store.db.query('SELECT inviter, invite_id, paid, reason FROM invite_bonuses WHERE invitee = ?').get(invitee) as { inviter: string; invite_id: string; paid: number; reason: string | null } | null + if (!row?.paid) return + await this.grantAdCredit(invitee, this.inviteBonusCents, `invite:${row.invite_id}:invitee`) + if (row.reason !== 'inviter_monthly_cap' && this.store.user(row.inviter)) await this.grantAdCredit(row.inviter, this.inviteBonusCents, `invite:${row.invite_id}:inviter`) + } + + myBonus(sub: string): { paid: boolean; reason: string | null } | null { + const row = this.store.db.query('SELECT paid, reason FROM invite_bonuses WHERE invitee = ?').get(sub) as { paid: number; reason: string | null } | null + return row ? { paid: Boolean(row.paid), reason: row.reason } : null + } +} diff --git a/src/server.ts b/src/server.ts index 6ba1d75..57634c4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,7 +11,8 @@ import QRCode from 'qrcode' import { authorizeUrl, exchangeCode, pkce } from '@profullstack/coinpay-onboard/oauth' import { CoinPay } from '@profullstack/coinpay-onboard' import { Sealer, Store, type Campaign, type DepositRow, type Site, type User } from './db.js' -import { adPage, advertisePage, depositPage, helpPage, homePage, installScript, mePage, messagePage, pitchPage, publishPage, sitePage, SITE, type Ctx } from './html.js' +import { adPage, advertisePage, depositPage, helpPage, homePage, installScript, inviteLandingPage, invitesPage, mePage, messagePage, pitchPage, publishPage, sitePage, SITE, type Ctx } from './html.js' +import { ACCEPT_MESSAGES, Rewards, type RewardsConfig } from './rewards.js' import { startPitFooterRefresh } from './pit-footer.js' import { RING_PLACEHOLDER, ringBar, startRingRefresh } from './ring.js' import { ringDescriptor, ringFooterScriptHashes, startRingFooterRefresh } from './ring-footer.js' @@ -22,6 +23,8 @@ export { VERSION } const SELF = 'ad.network.pit.world' const SESSION = 'an_session' +/** The invite a visitor arrived with (/i/), accepted at their CoinPay sign-in. */ +const INVITE = 'an_invite' const SCOPES = ['openid', 'profile', 'email', 'wallet:read'] const SECURITY_HEADERS: Record = { @@ -35,7 +38,7 @@ const SECURITY_HEADERS: Record = { 'cross-origin-opener-policy': 'same-origin', } -export type Deps = { store: Store; config: Config; origin?: string; publicDir?: string; secureCookies?: boolean } +export type Deps = { store: Store; config: Config; origin?: string; publicDir?: string; secureCookies?: boolean; rewards?: RewardsConfig } type Env = { Bindings: { ip?: string } } @@ -70,6 +73,7 @@ export function createApp(deps: Deps) { const { store, config } = deps const ORIGIN = deps.origin ?? SITE const net = new AdNetwork(store, { origin: ORIGIN, ...config }) + const rewards = new Rewards(store, net, deps.rewards ?? {}) const app = new Hono() const writeLimit = new Limiter(30, 10 * 60_000) const authLimit = new Limiter(30, 10 * 60_000) @@ -148,6 +152,13 @@ export function createApp(deps: Deps) { const user = store.upsertUser(me.sub, me.name ?? '', me.email ?? '', tokens) const sid = store.newSession(user.sub) setCookie(c, SESSION, sid, { httpOnly: true, secure: deps.secureCookies ?? true, sameSite: 'Lax', path: '/', maxAge: 30 * 86400 }) + // Came through an invite link: accept it now. Never blocks the sign-in. + const invite = getCookie(c, INVITE) + if (invite) { + deleteCookie(c, INVITE, { path: '/' }) + const r = await rewards.acceptInvite(user, invite).catch(() => ({ ok: false as const, reason: 'error' })) + return c.redirect(`/invites?invite=${encodeURIComponent(r.ok ? (r.paid ? 'paid' : 'accepted') : r.reason)}`) + } return c.redirect(`/${state.split('.')[1] ?? 'me'}`) } catch (e) { return page(c, messagePage(ctx, { title: 'Sign-in failed', message: e instanceof Error ? e.message : 'CoinPay did not answer.' }), 502) @@ -162,7 +173,7 @@ export function createApp(deps: Deps) { }) /** Signed-in pages: the user, or a redirect to sign in that comes back here. */ - const LANDING: Record = { advertise: 'advertise', ads: 'advertise', deposits: 'advertise', publish: 'publish', sites: 'publish' } + const LANDING: Record = { advertise: 'advertise', ads: 'advertise', deposits: 'advertise', publish: 'publish', sites: 'publish', invites: 'invites' } const need = (c: Context): User | Response => userOf(c) ?? c.redirect(`/auth/start?next=/${LANDING[c.req.path.split('/')[1] ?? ''] ?? 'me'}`) const formOf = async (c: Context) => (await c.req.parseBody()) as Record @@ -362,6 +373,94 @@ export function createApp(deps: Deps) { return c.redirect('/') }) + // ---- credit codes + invites ---------------------------------------------------------- + + app.post('/me/redeem', async (c) => { + const user = need(c) + if (user instanceof Response) return user + const form = await formOf(c) + const view = (extra: { flash?: string; error?: string }, status = 200) => page(c, mePage(ctxOf(c), { user, balanceCents: net.balanceCents(user.sub), ...extra }), status as 200) + if (!csrfOk(c, form.csrf)) return view({ error: 'That form went stale. Try again.' }, 400) + if (!writeLimit.allow(`w:${user.sub}`)) return view({ error: 'Too many changes. Wait a few minutes.' }, 429) + try { + const r = await rewards.redeem(user, form.code ?? '') + return view({ flash: `Code redeemed: ${(r.cents / 100).toFixed(2)} USD of ad credit added.` }) + } catch (e) { + return view({ error: message(e) }, statusOf(e)) + } + }) + + const INVITE_NOTES: Record = { + paid: 'Invite accepted: you both got ad credit.', + accepted: 'Invite accepted. This sign-up does not qualify for the bonus.', + error: 'We could not finish that invite. Try again from this page.', + ...ACCEPT_MESSAGES, + } + const invitesView = async (c: Context, user: User, extra: { flash?: string; error?: string; link?: string; code?: string } = {}, status = 200) => { + await rewards.repairInvite(user.sub).catch(() => {}) + const check = rewards.canInvite(user) + return page( + c, + invitesPage(ctxOf(c), { + quota: await rewards.affiliates.quota(user.sub), + invites: await rewards.affiliates.invites(user.sub), + bonusCents: rewards.inviteBonusCents, + canInvite: 'error' in check ? check.error : null, + myBonus: rewards.myBonus(user.sub), + ...extra, + }), + status, + ) + } + + app.get('/invites', async (c) => { + const user = need(c) + if (user instanceof Response) return user + const note = c.req.query('invite') + const ok = note === 'paid' || note === 'accepted' + return invitesView(c, user, { + ...(note && INVITE_NOTES[note] ? (ok ? { flash: INVITE_NOTES[note] } : { error: INVITE_NOTES[note] }) : {}), + ...(c.req.query('code') ? { code: String(c.req.query('code')).slice(0, 40) } : {}), + }) + }) + + app.post('/invites', async (c) => { + const user = need(c) + if (user instanceof Response) return user + const form = await formOf(c) + if (!csrfOk(c, form.csrf)) return invitesView(c, user, { error: 'That form went stale. Try again.' }, 400) + if (!writeLimit.allow(`w:${user.sub}`)) return invitesView(c, user, { error: 'Too many changes. Wait a few minutes.' }, 429) + try { + const inv = await rewards.createInvite(user, form.note ?? '') + return invitesView(c, user, { link: `${ORIGIN}/i/${inv.code}` }) + } catch (e) { + return invitesView(c, user, { error: message(e) }, statusOf(e)) + } + }) + + app.post('/invites/accept', async (c) => { + const user = need(c) + if (user instanceof Response) return user + const form = await formOf(c) + if (!csrfOk(c, form.csrf)) return invitesView(c, user, { error: 'That form went stale. Try again.' }, 400) + if (!writeLimit.allow(`w:${user.sub}`)) return invitesView(c, user, { error: 'Too many changes. Wait a few minutes.' }, 429) + try { + const r = await rewards.acceptInvite(user, form.code ?? '') + if (!r.ok) return invitesView(c, user, { error: INVITE_NOTES[r.reason] ?? 'That invite did not work.' }, 400) + return invitesView(c, user, { flash: INVITE_NOTES[r.paid ? 'paid' : 'accepted']! }) + } catch (e) { + return invitesView(c, user, { error: message(e) }, statusOf(e)) + } + }) + + // /i/: remember the invite for the CoinPay sign-in that follows (30 days). + app.get('/i/:code', (c) => { + const raw = c.req.param('code') + const code = /^[A-Za-z0-9-]{6,40}$/.test(raw) ? raw.toUpperCase() : null + if (code) setCookie(c, INVITE, code, { httpOnly: true, secure: deps.secureCookies ?? true, sameSite: 'Lax', path: '/', maxAge: 30 * 86400 }) + return page(c, inviteLandingPage(ctxOf(c), { code, bonusCents: rewards.inviteBonusCents }), code ? 200 : 404) + }) + // ---- the API ------------------------------------------------------------------------- const param = (c: Context, name: string) => c.req.param(name) ?? '' @@ -424,6 +523,41 @@ export function createApp(deps: Deps) { c.json({ name: u.name, email: u.email, balance_usd: Math.max(0, net.balanceCents(u.sub)) / 100, ads: store.campaigns(u.sub).map(adJson), deposits: store.deposits(u.sub).map(depositJson), sites: store.sites(u.sub).map(siteJson) }), ), ) + app.post( + '/api/v1/credits/redeem', + api(async (c, u) => { + const r = await rewards.redeem(u, String((await body(c)).code ?? '')) + return c.json({ ok: true, credit_usd: r.cents / 100, balance_usd: Math.max(0, net.balanceCents(u.sub)) / 100, spend_only: true }) + }), + ) + app.get( + '/api/v1/invites', + api(async (c, u) => { + const check = rewards.canInvite(u) + return c.json({ + quota: await rewards.affiliates.quota(u.sub), + bonus_usd: rewards.inviteBonusCents / 100, + can_invite: check.ok, + ...('error' in check ? { why_not: check.error } : {}), + invites: (await rewards.affiliates.invites(u.sub)).map((i) => ({ id: i.id, note: i.note, created_at: new Date(i.createdAt).toISOString(), expires_at: i.expiresAt ? new Date(i.expiresAt).toISOString() : null, used: Boolean(i.usedBy) })), + }) + }), + ) + app.post( + '/api/v1/invites', + api(async (c, u) => { + const inv = await rewards.createInvite(u, String((await body(c)).note ?? '')) + return c.json({ code: inv.code, link: `${ORIGIN}/i/${inv.code}`, expires_at: inv.expiresAt ? new Date(inv.expiresAt).toISOString() : null, left: inv.left }, 201) + }), + ) + app.post( + '/api/v1/invites/accept', + api(async (c, u) => { + const r = await rewards.acceptInvite(u, String((await body(c)).code ?? '')) + if (!r.ok) return c.json({ error: ACCEPT_MESSAGES[r.reason] ?? 'that invite did not work', reason: r.reason }, 400) + return c.json({ ok: true, bonus_usd: r.bonusCents / 100, ...(r.reason ? { no_bonus: r.reason } : {}) }) + }), + ) app.get('/api/v1/ads', api((c, u) => c.json({ ads: store.campaigns(u.sub).map(adJson) }))) app.post( '/api/v1/ads', @@ -502,7 +636,7 @@ export function createApp(deps: Deps) { console.error(error instanceof Error ? error.message : error) return c.req.path.startsWith('/api/') ? c.json({ error: 'Something went wrong.' }, 500) : page(c, messagePage(ctxOf(c), { title: 'Something went wrong', message: 'Try again in a moment.' }), 500) }) - return { app, net } + return { app, net, rewards } } export function serve(options: { port?: number; hostname?: string } = {}) { @@ -526,6 +660,10 @@ export function serve(options: { port?: number; hostname?: string } = {}) { houseSubs: (env.ADNETWORK_HOUSE_SUBS ?? '').split(',').map((s) => s.trim()).filter(Boolean), freeAds: (env.ADNETWORK_FREE_ADS ?? '1') !== '0', }, + rewards: { + ...(env.ADNETWORK_INVITE_BONUS_CENTS ? { inviteBonusCents: Number(env.ADNETWORK_INVITE_BONUS_CENTS) } : {}), + ...(env.ADNETWORK_INVITE_MONTHLY_CAP ? { inviteMonthlyCap: Number(env.ADNETWORK_INVITE_MONTHLY_CAP) } : {}), + }, }) const trustProxy = (env.ADNETWORK_TRUST_PROXY ?? '1') === '1' startRingRefresh() diff --git a/src/service.ts b/src/service.ts index 736dbfc..984e634 100644 --- a/src/service.ts +++ b/src/service.ts @@ -71,6 +71,7 @@ export const PAYOUT_COINS = ['usdc_pol', 'usdc_sol', 'usdc_base', 'usdc_eth'] as export function coinName(id: string): string { if (id === CHECKOUT_COIN) return 'Any coin or PayPal' + if (id === 'credit') return 'Ad credit (code or invite)' return COINS.find((c) => c.id === id)?.name ?? id.toUpperCase() } diff --git a/src/version.ts b/src/version.ts index 6d6b0d3..5c5211b 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.1.8' +export const VERSION = '0.2.0' diff --git a/test/app.test.ts b/test/app.test.ts index 4c5ce47..f7d48ee 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -20,6 +20,8 @@ function fakes() { withdrawn: [] as string[], calls: [] as string[], tokenOf: [] as string[], + grants: new Map(), + grantsDown: false, } let n = 0 const fetchImpl = (async (url: string | URL, init: RequestInit = {}) => { @@ -87,6 +89,14 @@ function fakes() { if (method === 'PATCH') Object.assign(slot, body) return json(200, slot) } + if (path === '/api/ads/v1/grants' && method === 'POST') { + if (s.grantsDown) return json(503, { error: 'down' }) + const applied = !s.grants.has(body.reference) + if (applied) s.grants.set(body.reference, Math.floor(Math.round(body.amount_usd * 100) / 5)) + const credits = s.grants.get(body.reference)! + const total = [...s.grants.values()].reduce((a, b) => a + b, 0) + return json(applied ? 201 : 200, { reference: body.reference, applied, credits, amount_usd: (credits * 5) / 100, ad_bonus_credits: total, spend_only: true }) + } return json(404, { error: `no ${method} ${path}` }) }) as typeof fetch return { s, fetchImpl } @@ -96,7 +106,7 @@ function setup(extra: { freeAds?: boolean } = {}) { const f = fakes() const pages = new Map() const store = new Store(':memory:', new Sealer(KEY)) - const { app, net } = createApp({ + const { app, net, rewards } = createApp({ store, secureCookies: false, origin: 'https://ad.network.pit.world', @@ -124,7 +134,8 @@ function setup(extra: { freeAds?: boolean } = {}) { return req(`/auth/callback?code=c1&state=${encodeURIComponent(loc.searchParams.get('state')!)}`) } const csrf = async (path: string) => /name="csrf" value="([0-9a-f]+)"/.exec(await (await req(path)).text())?.[1] ?? '' - return { f, store, net, req, signIn, csrf, pages } + const age = (sub: string, days: number) => store.db.query('UPDATE users SET created_at = ? WHERE sub = ?').run(Date.now() - days * 86_400_000, sub) + return { f, store, net, rewards, req, signIn, csrf, pages, age } } describe('ad.network', () => { @@ -403,3 +414,117 @@ describe('ad.network', () => { expect(['8.8.8.8', '23.95.228.174', '2606:4700::1111'].some(privateAddress)).toBe(false) }) }) + +describe('credit codes and invites', () => { + test('a code adds spend-only ad credit through crawlproof, once', async () => { + const t = setup({ freeAds: false }) + await t.signIn('u-alice') + const { code, id } = await t.rewards.credits.mint({ grant: { unit: 'usd_cents', amount: 1000 }, prefix: 'AD' }) + const me = await t.req('/me') + expect(await me.text()).toContain('Have a code?') + const r = await t.req('/me/redeem', { method: 'POST', form: { csrf: await t.csrf('/me'), code: code.toLowerCase() } }) + expect(await r.text()).toContain('10.00 USD of ad credit added') + expect(t.net.balanceCents('u-alice')).toBe(1000) + expect(t.f.s.grants.get(`ad.network:u-alice:code:${id}`)).toBe(200) + expect(t.store.deposit(`credit:code:${id}`)).toMatchObject({ coin: 'credit', status: 'complete', amountCents: 1000 }) + const again = await t.req('/me/redeem', { method: 'POST', form: { csrf: await t.csrf('/me'), code } }) + expect(again.status).toBe(400) + expect(await again.text()).toContain('You already redeemed that code.') + expect(t.net.balanceCents('u-alice')).toBe(1000) + expect(t.f.s.grants.size).toBe(1) + }) + + test('a grant crawlproof did not take is finished on the next try, never doubled', async () => { + const t = setup({ freeAds: false }) + await t.signIn('u-alice') + const { code, id } = await t.rewards.credits.mint({ grant: { unit: 'usd_cents', amount: 500 } }) + t.f.s.grantsDown = true + const r = await t.req('/me/redeem', { method: 'POST', form: { csrf: await t.csrf('/me'), code } }) + expect(r.status).toBe(502) + expect(t.net.balanceCents('u-alice')).toBe(0) + t.f.s.grantsDown = false + await t.req('/me/redeem', { method: 'POST', form: { csrf: await t.csrf('/me'), code } }) + expect(t.net.balanceCents('u-alice')).toBe(500) + expect(t.f.s.grants.get(`ad.network:u-alice:code:${id}`)).toBe(100) + }) + + test('invites: one per account; concurrent accepts, one wins and the other stays unused', async () => { + const t = setup({ freeAds: false }) + await t.signIn('u-alice') + await t.signIn('u-bob') + t.age('u-alice', 3) + t.age('u-bob', 3) + const alice = t.store.user('u-alice')! + const bob = t.store.user('u-bob')! + const ia = await t.rewards.createInvite(alice) + const ib = await t.rewards.createInvite(bob) + await t.signIn('u-carol') + const carol = t.store.user('u-carol')! + const [ra, rb] = await Promise.all([t.rewards.acceptInvite(carol, ia.code), t.rewards.acceptInvite(carol, ib.code)]) + const won = [ra, rb].filter((r) => r.ok) + const lost = [ra, rb].filter((r) => !r.ok) as { ok: false; reason: string }[] + expect(won).toHaveLength(1) + expect(lost[0]!.reason).toBe('already_invited') + expect(t.net.balanceCents('u-carol')).toBe(500) + const winner = ra.ok ? 'u-alice' : 'u-bob' + const loser = ra.ok ? 'u-bob' : 'u-alice' + expect(t.net.balanceCents(winner)).toBe(500) + expect(t.net.balanceCents(loser)).toBe(0) + expect((await t.rewards.affiliates.invites(loser))[0]!.usedBy).toBeNull() + }) + + test('invites arrive through /i/ and are accepted at CoinPay sign-in', async () => { + const t = setup({ freeAds: false }) + await t.signIn('u-alice') + t.age('u-alice', 3) + const html = await (await t.req('/invites', { method: 'POST', form: { csrf: await t.csrf('/invites'), note: 'for dan' } })).text() + const link = /https:\/\/ad\.network\.pit\.world\/i\/(INV-[A-Z0-9-]+)/.exec(html)![1]! + t.f.s.sub = 'u-dan' + const landing = await t.req(`/i/${link}`) + expect(landing.status).toBe(200) + expect(await landing.text()).toContain("You're invited") + // the landing set the an_invite cookie; sign in with it in the jar + const start = await t.req('/auth/start?next=/invites') + const state = new URL(start.headers.get('location')!).searchParams.get('state')! + const cb = await t.req(`/auth/callback?code=c1&state=${encodeURIComponent(state)}`) + expect(cb.headers.get('location')).toBe('/invites?invite=paid') + expect(t.net.balanceCents('u-dan')).toBe(500) + expect(t.net.balanceCents('u-alice')).toBe(500) + expect(await (await t.req('/invites')).text()).toContain('the ad credit is on your balance') + }) + + test('invite rules: new senders wait a day, old accounts cannot accept, same mailbox gets nothing', async () => { + const t = setup({ freeAds: false }) + await t.signIn('u-alice') + const alice = t.store.user('u-alice')! + await expect(t.rewards.createInvite(alice)).rejects.toThrow('hours old') + t.age('u-alice', 3) + const inv = await t.rewards.createInvite(t.store.user('u-alice')!) + await t.signIn('u-old') + t.age('u-old', 30) + expect(await t.rewards.acceptInvite(t.store.user('u-old')!, inv.code)).toEqual({ ok: false, reason: 'too_old' }) + expect((await t.rewards.affiliates.invites('u-alice'))[0]!.usedBy).toBeNull() + // the same mailbox as the inviter, spelled with a +tag + await t.signIn('u-alias') + t.store.db.query("UPDATE users SET email = 'u-alice+x@example.com' WHERE sub = 'u-alias'").run() + const r = await t.rewards.acceptInvite(t.store.user('u-alias')!, inv.code) + expect(r).toEqual({ ok: true, paid: false, reason: 'same_mailbox', bonusCents: 0 }) + expect(t.net.balanceCents('u-alias')).toBe(0) + }) + + test('the API, CLI and MCP surfaces redeem and invite', async () => { + const t = setup({ freeAds: false }) + await t.signIn('u-alice') + t.age('u-alice', 3) + const key = /(an_[A-Za-z0-9_-]+)<\/code>/.exec(await (await t.req('/me/key', { method: 'POST', form: { csrf: await t.csrf('/me') } })).text())![1]! + const auth = { authorization: `Bearer ${key}`, 'content-type': 'application/json' } + const { code } = await t.rewards.credits.mint({ grant: { unit: 'usd_cents', amount: 250 } }) + const r = await t.req('/api/v1/credits/redeem', { method: 'POST', headers: auth, body: JSON.stringify({ code }) }) + expect(await r.json()).toMatchObject({ ok: true, credit_usd: 2.5, balance_usd: 2.5, spend_only: true }) + const inv = await t.req('/api/v1/invites', { method: 'POST', headers: auth, body: JSON.stringify({ note: 'x' }) }) + expect(inv.status).toBe(201) + expect((await inv.json()).link).toMatch(/^https:\/\/ad\.network\.pit\.world\/i\/INV-/) + const list = await (await t.req('/api/v1/invites', { headers: auth })).json() + expect(list.quota).toEqual({ total: 3, used: 1, left: 2 }) + }) +})