diff --git a/README.md b/README.md index 367a094..516ad28 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,16 @@ claim code for one person, and withdrawals wait for it. - **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. +## Webhooks + +Customer webhooks via `@profullstack/webhooks` (Standard Webhooks signatures, CloudEvents bodies, durable +outbox in the same SQLite file, retried for about three days, SSRF-guarded URLs). Each event goes to the +account it is about: `ad.created`, `ad.started` / `ad.paused` (`reason`: `requested`, `funded`, `no_budget`), +`ad.deleted`, `budget.exhausted`, `deposit.completed`, `deposit.failed`, `credit.added`, `site.added`, +`site.verified`, `site.lost` (someone else proved a site you never did), `site.removed`, `payout.requested`. +Manage them at `/me#webhooks`, `/api/v1/webhooks` (list, create, test, rotate, deliveries, delete; API key), +`adnetwork webhooks` / `adnetwork webhook add|test|rotate|rm|log`, or MCP `create_webhook` and friends. + ## Run it ```sh diff --git a/bun.lock b/bun.lock index 2aa8aa9..057e60f 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@profullstack/footer": "^0.4.0", "@profullstack/hqtui": "^0.11.0", "@profullstack/redeem-credits": "0.2.0", + "@profullstack/webhooks": "0.2.1", "hono": "^4.13.14", "mailchecker": "^6.0.21", "qrcode": "^1.5.4", @@ -43,6 +44,8 @@ "@profullstack/referrals": ["@profullstack/referrals@0.1.0", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"] }, "sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg=="], + "@profullstack/webhooks": ["@profullstack/webhooks@0.2.1", "", {}, "sha512-QHeSkpu4O44oJ620qhQqxqFlD3u/OhupmXRnBHw4MGKuDTa0R+Y/727nnF7psSHxxSfnbWfNuVl15z3IXuaZiw=="], + "@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=="], diff --git a/package.json b/package.json index f3d9967..3344616 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/adnetwork", - "version": "0.2.0", + "version": "0.3.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", @@ -47,6 +47,7 @@ "@profullstack/footer": "^0.4.0", "@profullstack/hqtui": "^0.11.0", "@profullstack/redeem-credits": "0.2.0", + "@profullstack/webhooks": "0.2.1", "hono": "^4.13.14", "mailchecker": "^6.0.21", "qrcode": "^1.5.4", diff --git a/src/cli.ts b/src/cli.ts index aa24e29..3a5cbef 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -32,6 +32,11 @@ export const HELP = `adnetwork ${VERSION}: run ads, or earn from your site, in c adnetwork invite [note] make an invite link adnetwork accept accept an invite on your new account + adnetwork webhooks your webhook endpoints and the events ad.network sends + adnetwork webhook add [events...] POST ad, budget and site events to your URL (Standard Webhooks; prints the secret) + adnetwork webhook test|rotate|rm send a test, replace the secret, or remove it + adnetwork webhook log [id] recent deliveries + adnetwork mcp MCP server: claude mcp add adnetwork -- bunx @profullstack/adnetwork mcp adnetwork serve [--port N] run your own instance @@ -182,6 +187,30 @@ export async function main(argv = process.argv.slice(2), out: Out = console, cli 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 'webhooks': { + const r = await client.webhooks() + show(r, [...r.endpoints.map((w) => `${w.id} ${w.url} ${w.events.join(',')}${w.enabled ? '' : ` OFF (${w.disabled_reason})`}`), `events: ${r.event_types.join(', ')}`].join('\n')) + return 0 + } + case 'webhook': { + const sub = need(rest[0], 'add, test, rotate, rm or log') + if (sub === 'add') { + const r = await client.addWebhook(need(rest[1], 'a URL'), rest.slice(2)) + show(r, `${r.endpoint.id} -> ${r.endpoint.url}\nsigning secret (shown once): ${r.secret}`) + } else if (sub === 'test') { + const r = await client.testWebhook(need(rest[1], 'a webhook id')) + show(r, r.ok ? `delivered (HTTP ${r.status})` : `failed: ${r.error ?? r.status}`) + } else if (sub === 'rotate') { + const r = await client.rotateWebhook(need(rest[1], 'a webhook id')) + show(r, `new signing secret: ${r.secret}`) + } else if (sub === 'rm' || sub === 'remove' || sub === 'delete') { + show(await client.removeWebhook(need(rest[1], 'a webhook id')), 'removed') + } else if (sub === 'log') { + const r = await client.webhookLog(rest[1]) + show(r, r.deliveries.map((d) => `${d.created_at.slice(0, 19)} ${d.event_type.padEnd(18)} ${d.status.padEnd(9)} x${d.attempts} ${d.last_status ?? ''} ${d.last_error ?? ''}`).join('\n') || 'no deliveries yet') + } else throw new Error(`adnetwork webhook ${sub}? try add, test, rotate, rm or log`) + 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 3817a55..6e777df 100644 --- a/src/client.ts +++ b/src/client.ts @@ -6,6 +6,8 @@ export type ApiAd = { ref: string; name: string; url: string; daily_usd: number; running: boolean; wanted: string; status: string; headline: string; body: string; views: number; clicks: number; spent_usd: number; page: string } export type ApiDeposit = { id: string; amount_usd: number; coin: string; address: string; amount_crypto: string; checkout_url: string | null; status: string; expires_at: string | null; page: string } export type ApiSite = { site: string; tag: string; live: boolean; wallet: string | null; coin: string; earned_usd: number; withdrawable_usd: number; page: string } +export type ApiWebhook = { id: string; url: string; events: string[]; description: string; enabled: boolean; failures: number; disabled_reason: string | null; created_at: string } +export type ApiDelivery = { id: string; endpoint_id: string; event_type: string; status: string; attempts: number; last_status: number | null; last_error: string | null; created_at: string } export type ApiMe = { name: string; email: string; balance_usd: number; ads: ApiAd[]; deposits: ApiDeposit[]; sites: ApiSite[] } export class ApiError extends Error { @@ -91,6 +93,25 @@ export class AdNetworkClient { acceptInvite(code: string): Promise<{ ok: boolean; bonus_usd: number; no_bonus?: string }> { return this.call('POST', '/api/v1/invites/accept', { code }) } + /** Your webhook endpoints, and the events ad.network sends. */ + webhooks(): Promise<{ endpoints: ApiWebhook[]; event_types: string[] }> { + return this.call('GET', '/api/v1/webhooks') + } + addWebhook(url: string, events?: string[], description = ''): Promise<{ endpoint: ApiWebhook; secret: string }> { + return this.call('POST', '/api/v1/webhooks', { url, ...(events?.length ? { events } : {}), description }) + } + removeWebhook(id: string): Promise<{ deleted: true }> { + return this.call('DELETE', `/api/v1/webhooks/${encodeURIComponent(id)}`) + } + testWebhook(id: string): Promise<{ ok: boolean; status: number | null; error: string | null }> { + return this.call('POST', `/api/v1/webhooks/${encodeURIComponent(id)}/test`) + } + rotateWebhook(id: string): Promise<{ secret: string }> { + return this.call('POST', `/api/v1/webhooks/${encodeURIComponent(id)}/rotate`) + } + webhookLog(id?: string): Promise<{ deliveries: ApiDelivery[] }> { + return this.call('GET', id ? `/api/v1/webhooks/${encodeURIComponent(id)}/deliveries` : '/api/v1/webhooks/deliveries') + } removeSite(site: string): Promise<{ ok: boolean }> { return this.call('DELETE', `/api/v1/sites/${encodeURIComponent(site)}`) } diff --git a/src/html.ts b/src/html.ts index 4b307be..e4f94b2 100644 --- a/src/html.ts +++ b/src/html.ts @@ -8,7 +8,8 @@ import type { Campaign, DepositRow, Site, User, Withdrawal } from './db.js' import { pitFooter } from './pit-footer.js' import { RING_PLACEHOLDER } from './ring.js' import { ringFooter } from './ring-footer.js' -import { CLICK_CENTS, coinName, MAX_DAILY_USD, MIN_DAILY_USD, MIN_DEPOSIT_USD, MIN_PAYOUT_CENTS, PAYOUT_COINS, usd } from './service.js' +import type { PublicEndpoint } from '@profullstack/webhooks' +import { CLICK_CENTS, coinName, MAX_DAILY_USD, MIN_DAILY_USD, MIN_DEPOSIT_USD, MIN_PAYOUT_CENTS, PAYOUT_COINS, usd, WEBHOOK_EVENTS } from './service.js' export const SITE = 'https://ad.network.pit.world' export const REPO = 'https://github.com/moshcoder/ad.network' @@ -158,6 +159,12 @@ POST /api/v1/credits/redeem {"code": "AD-…"} -> ad credit (spend 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) +GET /api/v1/webhooks your webhook endpoints and the event types +POST /api/v1/webhooks {"url": "https://…", "events": ["ad.*", "deposit.completed"]} -> the signing secret +POST /api/v1/webhooks/:id/test send webhook.test now +POST /api/v1/webhooks/:id/rotate a new signing secret +GET /api/v1/webhooks/:id/deliveries recent deliveries +DELETE /api/v1/webhooks/:id -H 'authorization: Bearer an_…'
curl -fsSL ${SITE}/install.sh | sh          installs the adnetwork CLI (needs bun)
@@ -166,6 +173,7 @@ adnetwork run https://example.com --daily 5
 adnetwork fund 25
 adnetwork site example.com
 claude mcp add adnetwork -- bunx @profullstack/adnetwork mcp
+

Webhooks

Add a URL on your page (or adnetwork webhook add, MCP create_webhook, POST /api/v1/webhooks) and ad.network POSTs what happens to your ads, budget and sites: ${WEBHOOK_EVENTS.map((e) => `${esc(e)}`).join(', ')}. Each is a CloudEvents JSON body signed per Standard Webhooks (verify with @profullstack/webhooks or any Standard Webhooks library), retried for about three days.

Your data

We keep your CoinPay name and email, your sign-in (encrypted), which ads and sites are yours, and your deposits. Delete it all on your page.

Reaching ad.network

It lives at ad.network.pit.world, part of the Moshpit network.

`, }) @@ -330,7 +338,21 @@ function redeemCard(ctx: Ctx, code = ''): string {
` } -export function mePage(ctx: Ctx, o: { user: User; balanceCents: number; apiKey?: string; flash?: string; error?: string }): string { +function webhooksCard(ctx: Ctx, hooks: PublicEndpoint[], secret?: string): string { + return `

Webhooks

+

ad.network POSTs what happens to your ads, budget and sites to your URL: ${WEBHOOK_EVENTS.map((e) => `${esc(e)}`).join(', ')}. CloudEvents JSON, signed per Standard Webhooks; retried for about three days.

+${secret ? `

Signing secret, shown once:

${esc(secret)}
` : ''} +${hooks + .map( + (w) => `
${esc(w.url)}${esc(w.events.join(', '))}${w.enabled ? '' : ` · off: ${esc(w.disabled_reason ?? 'disabled')}`} +${(['test', 'rotate', 'delete'] as const).map((a) => `
${csrfField(ctx)}
`).join('')}
`, + ) + .join('')} +
${csrfField(ctx)}
+

Also from the CLI (adnetwork webhooks), MCP and /api/v1/webhooks.

` +} + +export function mePage(ctx: Ctx, o: { user: User; balanceCents: number; apiKey?: string; flash?: string; error?: string; webhooks?: PublicEndpoint[]; webhookSecret?: string }): string { const u = o.user return layout({ ctx, @@ -346,6 +368,7 @@ ${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)}
+${webhooksCard(ctx, o.webhooks ?? [], o.webhookSecret)}
Delete my account
${csrfField(ctx)}

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

`, }) } diff --git a/src/mcp.ts b/src/mcp.ts index edaadc9..1ed2678 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -52,6 +52,17 @@ export function createMcpServer(client = new AdNetworkClient()): McpServer { 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('list_webhooks', 'Your webhook endpoints and the event types ad.network sends.', {}, wrap(() => client.webhooks())) + server.tool( + 'create_webhook', + 'Have ad.network POST your ad, budget and site events (ad.created, ad.started, ad.paused, ad.deleted, budget.exhausted, deposit.completed, deposit.failed, credit.added, site.added, site.verified, site.lost, site.removed, payout.requested) to a URL, as signed CloudEvents (Standard Webhooks). Returns the signing secret, shown once. events: patterns like "ad.*"; omit for all.', + { url: z.string().url(), events: z.array(z.string()).optional(), description: z.string().max(200).optional() }, + wrap(({ url, events, description }) => client.addWebhook(url, events, description ?? '')), + ) + server.tool('test_webhook', 'Send a webhook.test event to an endpoint now.', { id: z.string() }, wrap(({ id }) => client.testWebhook(id))) + server.tool('rotate_webhook_secret', "Replace an endpoint's signing secret; the old one stops working.", { id: z.string() }, wrap(({ id }) => client.rotateWebhook(id))) + server.tool('delete_webhook', 'Remove a webhook endpoint.', { id: z.string() }, wrap(({ id }) => client.removeWebhook(id))) + server.tool('webhook_deliveries', 'Recent webhook deliveries, optionally for one endpoint.', { id: z.string().optional() }, wrap(({ id }) => client.webhookLog(id))) 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 index 8a5de94..96689dd 100644 --- a/src/rewards.ts +++ b/src/rewards.ts @@ -122,6 +122,8 @@ export class Rewards { 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 }) + // source is code: or invite::; the kind is all a receiver needs. + this.net.hook(sub, 'credit.added', { amount_usd: granted / 100, kind: source.split(':')[0], spend_only: true, balance_usd: this.net.balanceCents(sub) / 100 }, `account:${sub}`) await this.net.enforce(sub).catch(() => {}) return true } diff --git a/src/server.ts b/src/server.ts index 57634c4..3420096 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,6 +18,7 @@ import { RING_PLACEHOLDER, ringBar, startRingRefresh } from './ring.js' import { ringDescriptor, ringFooterScriptHashes, startRingFooterRefresh } from './ring-footer.js' import { AdNetwork, newApiKey, UserError, type Config } from './service.js' import { VERSION } from './version.js' +import { webhooksApi } from '@profullstack/webhooks' export { VERSION } @@ -351,23 +352,52 @@ export function createApp(deps: Deps) { // ---- me --------------------------------------------------------------------------- - app.get('/me', (c) => { + app.get('/me', async (c) => { const user = need(c) - return user instanceof Response ? user : page(c, mePage(ctxOf(c), { user, balanceCents: net.balanceCents(user.sub) })) + return user instanceof Response ? user : page(c, mePage(ctxOf(c), { user, balanceCents: net.balanceCents(user.sub), webhooks: await net.hooks.listEndpoints(user.sub) })) + }) + + // Webhooks from the account page: add, test, rotate, delete. The API below does the same with a key. + app.post('/me/webhooks/:action{(add|test|rotate|delete)}', async (c) => { + const user = need(c) + if (user instanceof Response) return user + const form = await formOf(c) + const show = async (data: { flash?: string; error?: string; webhookSecret?: string }, status = 200) => + page(c, mePage(ctxOf(c), { user, balanceCents: net.balanceCents(user.sub), webhooks: await net.hooks.listEndpoints(user.sub), ...data }), status) + if (!csrfOk(c, form.csrf)) return show({ error: 'That form went stale. Try again.' }, 400) + if (!writeLimit.allow(`w:${user.sub}`)) return show({ error: 'Too many changes. Wait a few minutes.' }, 429) + try { + const action = c.req.param('action') + if (action === 'add') { + const r = await net.hooks.createEndpoint(user.sub, { url: form.url ?? '', events: form.events || undefined, description: form.description ?? '' }) + return show({ flash: 'Webhook added. Its signing secret is below; copy it now.', webhookSecret: r.secret }) + } + const id = form.id ?? '' + if (action === 'test') { + const r = await net.hooks.test(user.sub, id) + return r.ok ? show({ flash: `Test delivered (HTTP ${r.status}).` }) : show({ error: `Test failed: ${r.error ?? `HTTP ${r.status}`}` }) + } + if (action === 'rotate') return show({ flash: 'New signing secret below; the old one has stopped working.', webhookSecret: (await net.hooks.rotateSecret(user.sub, id)).secret }) + await net.hooks.deleteEndpoint(user.sub, id) + return show({ flash: 'Webhook removed.' }) + } catch (e) { + return show({ error: message(e) }, e instanceof UserError ? e.status : ((e as { status?: number })?.status ?? 400)) + } }) app.post('/me/:action{(key|delete)}', async (c) => { const user = need(c) if (user instanceof Response) return user const form = await formOf(c) - if (!csrfOk(c, form.csrf)) return page(c, mePage(ctxOf(c), { user, balanceCents: net.balanceCents(user.sub), flash: 'That form went stale. Try again.' }), 400) + if (!csrfOk(c, form.csrf)) return page(c, mePage(ctxOf(c), { user, balanceCents: net.balanceCents(user.sub), webhooks: await net.hooks.listEndpoints(user.sub), flash: 'That form went stale. Try again.' }), 400) if (c.req.param('action') === 'key') { const key = newApiKey() store.setApiKey(user.sub, key) - return page(c, mePage(ctxOf(c), { user: store.user(user.sub)!, balanceCents: net.balanceCents(user.sub), apiKey: key })) + return page(c, mePage(ctxOf(c), { user: store.user(user.sub)!, balanceCents: net.balanceCents(user.sub), webhooks: await net.hooks.listEndpoints(user.sub), apiKey: key })) } for (const ad of store.campaigns(user.sub)) await net.ads.updateCampaign(ad.id, { status: 'paused' }).catch(() => {}) for (const s of store.sites(user.sub)) await net.pubs.updateSlot(s.slotId, { status: 'paused' }).catch(() => {}) + for (const w of await net.hooks.listEndpoints(user.sub)) await net.hooks.deleteEndpoint(user.sub, w.id).catch(() => {}) store.deleteUser(user.sub) deleteCookie(c, SESSION, { path: '/' }) return c.redirect('/') @@ -379,7 +409,7 @@ export function createApp(deps: Deps) { 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) + const view = async (extra: { flash?: string; error?: string }, status = 200) => page(c, mePage(ctxOf(c), { user, balanceCents: net.balanceCents(user.sub), webhooks: await net.hooks.listEndpoints(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 { @@ -517,6 +547,16 @@ export function createApp(deps: Deps) { return s } + // Customer webhooks: the @profullstack/webhooks API, scoped to the key's account. + const hookOwner = new WeakMap() + const hooksApi = webhooksApi(net.hooks, { basePath: '/api/v1/webhooks', owner: (req) => hookOwner.get(req) }) + const webhooksRoute = api(async (c, u) => { + hookOwner.set(c.req.raw, u.sub) + return (await hooksApi(c.req.raw)) ?? c.json({ error: 'not found' }, 404) + }) + app.all('/api/v1/webhooks', webhooksRoute) + app.all('/api/v1/webhooks/*', webhooksRoute) + app.get( '/api/v1/me', api((c, u) => @@ -670,6 +710,7 @@ export function serve(options: { port?: number; hostname?: string } = {}) { startPitFooterRefresh('ad.network.pit.world') startRingFooterRefresh() net.startSweep() + net.hooks.start() return Bun.serve({ port: options.port ?? Number(env.PORT ?? 8800), hostname: options.hostname ?? env.HOST ?? '127.0.0.1', diff --git a/src/service.ts b/src/service.ts index 984e634..d0b6dae 100644 --- a/src/service.ts +++ b/src/service.ts @@ -19,6 +19,7 @@ import { lookup } from 'node:dns/promises' import { isIP } from 'node:net' import { COINS, CoinPay } from '@profullstack/coinpay-onboard' import { refresh as refreshTokens } from '@profullstack/coinpay-onboard/oauth' +import { Webhooks, sqliteStore as webhookStore, type GuardOptions } from '@profullstack/webhooks' import { Crawlproof, CrawlproofError } from './crawlproof.js' import type { Campaign, DepositRow, Site, Store, User } from './db.js' @@ -46,8 +47,30 @@ export type Config = { fetch?: typeof fetch /** Fetches a publisher's page to look for their tag; tests swap it. */ fetchPage?: (url: string) => Promise + /** Checks on customer webhook URLs; tests allow a fixed public address. */ + webhookGuard?: GuardOptions } +/** + * What ad.network tells customer webhooks about. The owner of each event is the + * advertiser or publisher it is about (their CoinPay sub). + */ +export const WEBHOOK_EVENTS = [ + 'ad.created', + 'ad.started', + 'ad.paused', + 'ad.deleted', + 'budget.exhausted', + 'deposit.completed', + 'deposit.failed', + 'credit.added', + 'site.added', + 'site.verified', + 'site.lost', + 'site.removed', + 'payout.requested', +] + export class UserError extends Error { constructor(message: string, readonly field?: string, readonly status = 400) { super(message) @@ -159,6 +182,8 @@ export async function fetchPublicPage(url: string, hops = 3): Promise { export class AdNetwork { readonly ads: Crawlproof readonly pubs: Crawlproof + /** Customer webhooks (@profullstack/webhooks): endpoints per account, durable delivery. */ + readonly hooks: Webhooks private refreshing = new Map>() constructor( @@ -167,6 +192,23 @@ export class AdNetwork { ) { this.ads = new Crawlproof(config.crawlproofBase, config.advertiserToken, config.fetch) this.pubs = new Crawlproof(config.crawlproofBase, config.publisherToken, config.fetch) + this.hooks = new Webhooks({ + store: webhookStore(store.db as never), + source: (config.origin ?? 'https://ad.network.pit.world').replace(/\/+$/, ''), + eventTypes: WEBHOOK_EVENTS, + ...(config.fetch ? { fetchImpl: config.fetch } : {}), + ...(config.webhookGuard ? { guard: config.webhookGuard } : {}), + log: (m) => console.log(m), + }) + } + + /** Tell an account's webhooks; never in the way of what caused it. */ + hook(sub: string, type: string, data: Record, subject?: string): void { + void this.hooks.emit(sub, type, data, subject ? { subject } : {}).catch((e) => console.error('ad.network: webhook:', e instanceof Error ? e.message : e)) + } + + private adData(c: Campaign): Record { + return { ref: c.ref, name: c.name, url: c.url, daily_usd: c.dailyBudgetCents / 100, status: c.status, wanted: c.wanted, spent_usd: c.spentCents / 100 } } private house(sub: string): boolean { @@ -247,7 +289,8 @@ export class AdNetwork { const creative = Array.isArray(c.creatives) ? (c.creatives.find((k) => k.headline) ?? c.creatives[0]) : undefined // crawlproof's own form of the page (it drops the query string) is the one it serves. const row = this.store.addCampaign({ id: c.id, ref: c.ref_slug, sub: user.sub, url: c.destination_url || url, name: c.name, dailyBudgetCents: daily, status: c.status, headline: creative?.headline ?? '', body: creative?.body ?? '' }) - await this.enforce(user.sub).catch(() => {}) + this.hook(user.sub, 'ad.created', this.adData(row), `ad:${row.ref}`) + await this.enforce(user.sub, 'requested').catch(() => {}) return this.store.campaign(row.id)! } @@ -260,7 +303,7 @@ export class AdNetwork { async setRunning(user: User, ref: string, running: boolean): Promise { const c = this.own(user, ref) this.store.updateCampaign(c.id, { wanted: running ? 'active' : 'paused' }) - await this.enforce(user.sub) + await this.enforce(user.sub, 'requested') return this.store.campaign(c.id)! } @@ -281,6 +324,7 @@ export class AdNetwork { if (!(e instanceof CrawlproofError && e.status === 404)) throw e }) this.store.removeCampaign(c.id) + this.hook(user.sub, 'ad.deleted', this.adData(c), `ad:${c.ref}`) } /** Re-reads a campaign's numbers from crawlproof. */ @@ -299,8 +343,9 @@ export class AdNetwork { * Runs what should run and pauses what should not: an ad runs when its owner * wants it to and they can run (free launch, or a balance above zero). */ - async enforce(sub: string): Promise { + async enforce(sub: string, cause: 'requested' | 'funded' = 'funded'): Promise { const funded = this.canRun(sub) + let starved = 0 for (const c of this.store.campaigns(sub)) { const target = c.wanted === 'active' && funded ? 'active' : 'paused' const current = c.status === 'active' ? 'active' : 'paused' @@ -310,11 +355,17 @@ export class AdNetwork { try { const r = await this.ads.updateCampaign(c.id, { status: target }) this.store.updateCampaign(c.id, { status: r.status ?? target }) + const now = this.store.campaign(c.id)! + // Why it changed: the owner asked, the money ran out, or budget arrived. + const reason = target === 'active' ? cause : c.wanted === 'active' ? 'no_budget' : 'requested' + if (target === 'paused' && reason === 'no_budget') starved += 1 + this.hook(sub, target === 'active' ? 'ad.started' : 'ad.paused', { ...this.adData(now), reason }, `ad:${c.ref}`) } catch (e) { // 409: crawlproof is still writing the ad. The next sweep tries again. if (!(e instanceof CrawlproofError && e.status === 409)) throw e } } + if (starved) this.hook(sub, 'budget.exhausted', { balance_usd: this.balanceCents(sub) / 100, ads_paused: starved }, `account:${sub}`) } /** @@ -358,6 +409,9 @@ export class AdNetwork { const d = await this.ads.depositStatus(id) if (d.status !== row.status) { this.store.setDepositStatus(id, d.status) + const data = { id, amount_usd: row.amountCents / 100, status: d.status, balance_usd: this.balanceCents(row.sub) / 100 } + if (d.status === 'complete') this.hook(row.sub, 'deposit.completed', data, `deposit:${id}`) + else if (d.status !== 'pending') this.hook(row.sub, 'deposit.failed', data, `deposit:${id}`) if (d.status === 'complete') await this.enforce(row.sub).catch(() => {}) } } catch { @@ -389,7 +443,11 @@ export class AdNetwork { if (!proven && Date.now() - existing.createdAt < CLAIM_GRACE_MS) { throw new UserError(`${host} was claimed by someone else and is waiting for their tag. If it is yours, add to your home page and try again.`, 'site', 409) } - return this.store.claimSite({ slotId: existing.slotId, sub: user.sub, host, embed: existing.embed }) + const taken = this.store.claimSite({ slotId: existing.slotId, sub: user.sub, host, embed: existing.embed }) + // The one who never placed their tag hears it went to someone who did. + this.hook(existing.sub, 'site.lost', { site: host, reason: proven ? 'proven_by_another_account' : 'claim_expired' }, `site:${host}`) + this.hook(user.sub, 'site.added', this.siteData(taken), `site:${host}`) + return taken } if (!this.house(user.sub) && this.store.sites(user.sub).length >= 25) throw new UserError('You have 25 sites already.') let slot @@ -399,7 +457,21 @@ export class AdNetwork { throw new UserError(e instanceof CrawlproofError && e.status < 500 ? e.message : 'The ad service did not answer. Try again in a minute.', 'site', 502) } // Another publisher's slot for the same host would come back as `existing`; the host check above already handled it. - return this.store.claimSite({ slotId: slot.id, sub: user.sub, host, embed: slot.embed ?? '' }) + const site = this.store.claimSite({ slotId: slot.id, sub: user.sub, host, embed: slot.embed ?? '' }) + this.hook(user.sub, 'site.added', this.siteData(site), `site:${host}`) + return site + } + + private siteData(s: Site): Record { + return { site: s.host, live: Boolean(s.verifiedAt), wallet: s.payoutAddress || null, coin: s.payoutCoin, earned_usd: s.earnedCents / 100, withdrawable_usd: s.availableCents / 100 } + } + + /** Marks a site proven (its tag was seen) and tells its owner's webhooks. */ + private markVerified(s: Site): Site { + this.store.updateSite(s.slotId, { verifiedAt: Date.now() }) + const now = this.store.site(s.slotId)! + this.hook(s.sub, 'site.verified', this.siteData(now), `site:${s.host}`) + return now } private ownSite(user: User, idOrHost: string): Site { @@ -419,8 +491,7 @@ export class AdNetwork { if (s.verifiedAt) return s const found = await this.pageCarries(s.host, this.claimCode(user.sub, s.host)) if (!found) throw new UserError(`We did not find your tag on https://${s.host}/ yet. Paste it into the page (before ), publish, and check again.`) - this.store.updateSite(s.slotId, { verifiedAt: Date.now() }) - return this.store.site(s.slotId)! + return this.markVerified(s) } async setWallet(user: User, idOrHost: string, address: string, coin?: string): Promise { @@ -446,6 +517,7 @@ export class AdNetwork { try { const r = await this.pubs.withdraw(s.slotId) this.store.addWithdrawal({ slotId: s.slotId, sub: user.sub, amountCents: r.amount_cents, status: r.status }) + this.hook(user.sub, 'payout.requested', { site: s.host, amount_usd: r.amount_cents / 100, status: r.status, wallet: s.payoutAddress, coin: s.payoutCoin }, `site:${s.host}`) await this.refreshSite(s).catch(() => {}) return { amountCents: r.amount_cents, status: r.status } } catch (e) { @@ -458,6 +530,7 @@ export class AdNetwork { const s = this.ownSite(user, idOrHost) await this.pubs.updateSlot(s.slotId, { status: 'paused' }).catch(() => {}) this.store.removeSite(s.slotId) + this.hook(user.sub, 'site.removed', this.siteData(s), `site:${s.host}`) } // ---- the sweep ---------------------------------------------------------------- @@ -468,7 +541,7 @@ export class AdNetwork { for (const d of this.store.pendingDeposits(sub)) await this.followDeposit(d.id) await this.enforce(sub).catch((e) => console.error(`enforce ${sub}: ${e instanceof Error ? e.message : e}`)) for (const s of this.store.sites(sub)) { - if (!s.verifiedAt && (await this.pageCarries(s.host, this.claimCode(sub, s.host)).catch(() => false))) this.store.updateSite(s.slotId, { verifiedAt: Date.now() }) + if (!s.verifiedAt && (await this.pageCarries(s.host, this.claimCode(sub, s.host)).catch(() => false))) this.markVerified(s) await this.refreshSite(s).catch(() => {}) } } diff --git a/src/version.ts b/src/version.ts index 5c5211b..1eb951b 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.2.0' +export const VERSION = '0.3.0' diff --git a/test/app.test.ts b/test/app.test.ts index f7d48ee..7891dd2 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -5,6 +5,9 @@ import { cleanHost, cleanUrl, privateAddress } from '../src/service.ts' import { onKey, initialState, pick, rows } from '../src/tui.ts' import { flag, main } from '../src/cli.ts' import { AdNetworkClient } from '../src/client.ts' +import { createMcpServer } from '../src/mcp.ts' +import { verifySignature } from '@profullstack/webhooks' +import type { Config } from '../src/service.ts' const KEY = 'test-key-0123456789abcdef' @@ -22,6 +25,8 @@ function fakes() { tokenOf: [] as string[], grants: new Map(), grantsDown: false, + /** What customer webhook receivers got. */ + hooks: [] as { url: string; headers: Headers; raw: string; body: any }[], } let n = 0 const fetchImpl = (async (url: string | URL, init: RequestInit = {}) => { @@ -32,6 +37,10 @@ function fakes() { const body: any = raw.startsWith('{') ? JSON.parse(raw) : raw ? Object.fromEntries(new URLSearchParams(raw)) : {} const path = u.pathname const auth = new Headers(init.headers).get('authorization') ?? '' + if (u.host.startsWith('hooks.')) { + s.hooks.push({ url: String(url), headers: new Headers(init.headers), raw, body }) + return new Response('ok', { status: 200 }) + } if (u.host === 'cp.test') { if (path === '/api/oauth/token') return json(200, { access_token: 'at', refresh_token: 'rt', expires_in: 3600, scope: 'openid profile email wallet:read' }) if (path === '/api/oauth/userinfo') return json(200, { sub: s.sub, name: s.sub === 'u-alice' ? 'Alice Smith' : 'Bob', email: `${s.sub}@example.com` }) @@ -102,7 +111,7 @@ function fakes() { return { s, fetchImpl } } -function setup(extra: { freeAds?: boolean } = {}) { +function setup(extra: Partial = {}) { const f = fakes() const pages = new Map() const store = new Store(':memory:', new Sealer(KEY)) @@ -528,3 +537,132 @@ describe('credit codes and invites', () => { expect(list.quota).toEqual({ total: 3, used: 1, left: 2 }) }) }) + +describe('customer webhooks', () => { + // Webhook URLs resolve to a public address in tests (the real guard does DNS). + const guard = { webhookGuard: { lookup: async () => ['93.184.216.34'] } } + + test('the API is behind the key and scoped to its account', async () => { + const t = setup(guard) + t.store.upsertUser('u-alice', 'Alice', 'a@example.com', { accessToken: 'a', refreshToken: null, expiresAt: 0 }) + t.store.upsertUser('u-bob', 'Bob', 'b@example.com', { accessToken: 'a', refreshToken: null, expiresAt: 0 }) + t.store.setApiKey('u-alice', 'an_alice') + t.store.setApiKey('u-bob', 'an_bob') + const call = (key: string, method: string, path: string, body?: unknown) => + t.req(path, { method, headers: { authorization: `Bearer ${key}`, ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) }) + expect((await t.req('/api/v1/webhooks')).status).toBe(401) + expect((await t.req('/api/v1/webhooks', { method: 'POST' })).status).toBe(401) + const created = await call('an_alice', 'POST', '/api/v1/webhooks', { url: 'https://hooks.example.com/an', events: ['ad.*', 'deposit.completed'] }) + expect(created.status).toBe(201) + const { endpoint, secret } = (await created.json()) as any + expect(secret).toStartWith('whsec_') + const list = (await (await call('an_alice', 'GET', '/api/v1/webhooks')).json()) as any + expect(list.endpoints.map((e: any) => e.id)).toEqual([endpoint.id]) + expect(list.event_types).toContain('budget.exhausted') + expect(((await (await call('an_bob', 'GET', '/api/v1/webhooks')).json()) as any).endpoints).toEqual([]) + expect((await call('an_bob', 'GET', `/api/v1/webhooks/${endpoint.id}`)).status).toBe(404) + expect((await call('an_bob', 'DELETE', `/api/v1/webhooks/${endpoint.id}`)).status).toBe(404) + expect((await call('an_alice', 'POST', '/api/v1/webhooks', { url: 'https://hooks.example.com/x', events: ['nope.*'] })).status).toBe(400) + expect((await call('an_alice', 'POST', '/api/v1/webhooks', { url: 'http://127.0.0.1/x' })).status).toBe(400) + const tested = (await (await call('an_alice', 'POST', `/api/v1/webhooks/${endpoint.id}/test`)).json()) as any + expect(tested).toMatchObject({ ok: true, status: 200 }) + expect(t.f.s.hooks.at(-1)!.body).toMatchObject({ type: 'webhook.test' }) + }) + + test('an advertiser and a publisher hear about their own ads, money and sites, signed', async () => { + const t = setup(guard) + await t.signIn('u-alice') + // Add a webhook from the account page: the secret is shown once. + const added = await (await t.req('/me/webhooks/add', { method: 'POST', form: { csrf: await t.csrf('/me'), url: 'https://hooks.example.com/alice', events: '' } })).text() + const secret = /(whsec_[A-Za-z0-9+/=]+)/.exec(added)![1]! + expect(added).toContain('https://hooks.example.com/alice') + const id = (await t.net.hooks.listEndpoints('u-alice'))[0]!.id + const tested = await (await t.req('/me/webhooks/test', { method: 'POST', form: { csrf: await t.csrf('/me'), id } })).text() + expect(tested).toContain('Test delivered (HTTP 200)') + t.f.s.hooks.length = 0 + + // Advertiser: an ad, a deposit that lands, then the budget runs out. + const made = await t.req('/advertise', { method: 'POST', form: { csrf: await t.csrf('/advertise'), url: 'example.com/launch', daily: '5' } }) + const ref = made.headers.get('location')!.split('/')[2]!.split('?')[0]! + const funded = await t.req('/advertise/fund', { method: 'POST', form: { csrf: await t.csrf('/advertise'), amount: '25' } }) + const depositId = funded.headers.get('location')!.split('/').pop()! + t.f.s.deposits.get(depositId).status = 'complete' + await t.req(`/deposits/${depositId}.json`) + t.f.s.campaigns.get('c1').stats = { impressions: 900, clicks: 125, spent_cents: 2500, spend_today_cents: 500, free_impressions: 100, free_clicks: 5 } + await t.net.sync('u-alice') + + // Publisher: a site, proven, paid out. + await t.req('/publish', { method: 'POST', form: { csrf: await t.csrf('/publish'), site: 'myblog.com' } }) + const site = t.store.site('myblog.com')! + const csrf = await t.csrf('/sites/myblog.com') + await t.req('/sites/myblog.com/wallet', { method: 'POST', form: { csrf, address: '0xAliceWallet000000000000000000000000000001', coin: 'usdc_pol' } }) + t.pages.set('myblog.com', t.net.embedFor(site)) + await t.req('/sites/myblog.com/check', { method: 'POST', form: { csrf } }) + const slot = t.f.s.slots.get(site.slotId) + slot.available_cents = 740 + await t.req('/sites/myblog.com/withdraw', { method: 'POST', form: { csrf } }) + + // Bob takes an unproven claim of Alice's: Alice hears she lost it; Bob hears nothing of Alice's. + await t.req('/publish', { method: 'POST', form: { csrf: await t.csrf('/publish'), site: 'contested.org' } }) + await t.signIn('u-bob') + t.pages.set('contested.org', ``) + await t.req('/publish', { method: 'POST', form: { csrf: await t.csrf('/publish'), site: 'contested.org' } }) + + await t.net.hooks.drain() + const got = t.f.s.hooks.filter((h) => h.url === 'https://hooks.example.com/alice') + expect(got.length).toBe(t.f.s.hooks.length) + const types = got.map((h) => h.body.type) + expect(types).toEqual(expect.arrayContaining(['ad.created', 'deposit.completed', 'ad.started', 'ad.paused', 'budget.exhausted', 'site.added', 'site.verified', 'payout.requested', 'site.lost'])) + const by = (type: string) => got.find((h) => h.body.type === type)!.body + expect(by('ad.created')).toMatchObject({ specversion: '1.0', source: 'https://ad.network.pit.world', subject: `ad:${ref}`, data: { ref, url: 'https://example.com/launch', daily_usd: 5, status: 'draft' } }) + expect(by('deposit.completed').data).toMatchObject({ id: depositId, amount_usd: 25, status: 'complete', balance_usd: 25 }) + expect(by('ad.started').data).toMatchObject({ ref, reason: 'funded' }) + expect(by('ad.paused').data).toMatchObject({ ref, reason: 'no_budget' }) + expect(by('budget.exhausted').data).toMatchObject({ balance_usd: 0, ads_paused: 1 }) + expect(by('site.verified')).toMatchObject({ subject: 'site:myblog.com', data: { site: 'myblog.com', live: true } }) + expect(by('payout.requested').data).toMatchObject({ site: 'myblog.com', amount_usd: 7.4, status: 'confirmed' }) + expect(by('site.lost').data).toEqual({ site: 'contested.org', reason: 'proven_by_another_account' }) + + // Every delivery verifies against the secret the page showed. + for (const h of got) expect(verifySignature({ headers: h.headers, body: h.raw, secret }).ok).toBe(true) + expect(verifySignature({ headers: got[0]!.headers, body: got[0]!.raw, secret: 'whsec_' + Buffer.from('wrong-secret-0123456789').toString('base64') }).ok).toBe(false) + + // Rotate and remove from the page. + await t.signIn('u-alice') + expect(await (await t.req('/me')).text()).toContain('hooks.example.com/alice') + const rotated = await (await t.req('/me/webhooks/rotate', { method: 'POST', form: { csrf: await t.csrf('/me'), id } })).text() + expect(/(whsec_[A-Za-z0-9+/=]+)/.exec(rotated)![1]).not.toBe(secret) + await t.req('/me/webhooks/delete', { method: 'POST', form: { csrf: await t.csrf('/me'), id } }) + expect(await t.net.hooks.listEndpoints('u-alice')).toEqual([]) + }) + + test('the CLI and MCP manage webhooks', async () => { + const t = setup(guard) + await t.signIn() + const key = /(an_[A-Za-z0-9_-]+)/.exec(await (await t.req('/me/key', { method: 'POST', form: { csrf: await t.csrf('/me') } })).text())![1]! + const viaApp = (async (url: string | URL, init?: RequestInit) => { + const u = new URL(String(url)) + return t.req(u.pathname + u.search, { ...init, headers: new Headers(init?.headers) }) + }) as typeof fetch + const client = new AdNetworkClient('https://ad.network.pit.world', key, viaApp) + const lines: string[] = [] + const out = { log: (s: string) => lines.push(s), error: (s: string) => lines.push(`ERR ${s}`) } + expect(await main(['webhook', 'add', 'https://hooks.example.com/cli', 'site.*'], out, client)).toBe(0) + expect(lines.at(-1)).toContain('signing secret (shown once): whsec_') + const id = lines.at(-1)!.split(' ')[0]! + expect(await main(['webhooks'], out, client)).toBe(0) + expect(lines.at(-1)).toContain(`${id} https://hooks.example.com/cli site.*`) + expect(await main(['webhook', 'test', id], out, client)).toBe(0) + expect(lines.at(-1)).toBe('delivered (HTTP 200)') + expect(await main(['webhook', 'log'], out, client)).toBe(0) + expect(lines.at(-1)).toContain('webhook.test') + expect(await main(['webhook', 'rm', id], out, client)).toBe(0) + expect((await client.webhooks()).endpoints).toEqual([]) + + const server: any = createMcpServer(client) + const tools = Object.keys(server._registeredTools) + expect(tools).toEqual(expect.arrayContaining(['list_webhooks', 'create_webhook', 'test_webhook', 'rotate_webhook_secret', 'delete_webhook', 'webhook_deliveries'])) + const r = await server._registeredTools.create_webhook.handler({ url: 'https://hooks.example.com/mcp', events: ['ad.*'] }, {}) + expect(JSON.parse(r.content[0].text).secret).toStartWith('whsec_') + }) +})