Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ claim code for one person, and withdrawals wait for it.
- **Invites** (`@profullstack/affiliates`): 3 per account, `/invites`, `/i/<code>` (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:<source>`) 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
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <invite-code> accept an invite on your new account

adnetwork webhooks your webhook endpoints and the events ad.network sends
adnetwork webhook add <url> [events...] POST ad, budget and site events to your URL (Standard Webhooks; prints the secret)
adnetwork webhook test|rotate|rm <id> 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

Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)}`)
}
Expand Down
27 changes: 25 additions & 2 deletions src/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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_…'</pre>
<pre>curl -fsSL ${SITE}/install.sh | sh installs the adnetwork CLI (needs bun)
Expand All @@ -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</pre></div>
<div class="card" id="webhooks"><h2>Webhooks</h2><p>Add a URL on <a href="/me#webhooks">your page</a> (or <code>adnetwork webhook add</code>, MCP <code>create_webhook</code>, <code>POST /api/v1/webhooks</code>) and ad.network POSTs what happens to your ads, budget and sites: ${WEBHOOK_EVENTS.map((e) => `<code>${esc(e)}</code>`).join(', ')}. Each is a CloudEvents JSON body signed per <a href="https://www.standardwebhooks.com/">Standard Webhooks</a> (verify with <code>@profullstack/webhooks</code> or any Standard Webhooks library), retried for about three days.</p></div>
<div class="card"><h2>Your data</h2><p>We keep your CoinPay name and email, your sign-in (encrypted), which ads and sites are yours, and your deposits. Delete it all on <a href="/me">your page</a>.</p></div>
<div class="card"><h2>Reaching ad.network</h2><p>It lives at <a href="${SITE}/">ad.network.pit.world</a>, part of the <a href="https://pit.world">Moshpit</a> network.</p></div>`,
})
Expand Down Expand Up @@ -330,7 +338,21 @@ function redeemCard(ctx: Ctx, code = ''): string {
<div class="row"><input name="code" required placeholder="AD-XXXX-XXXX-XXXX-XXXX" value="${esc(code)}" autocomplete="off" spellcheck="false" aria-label="Credit code"><button class="btn">Redeem</button></div></form>`
}

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 `<div class="card" id="webhooks"><h2>Webhooks</h2>
<p class="muted small">ad.network POSTs what happens to your ads, budget and sites to your URL: ${WEBHOOK_EVENTS.map((e) => `<code>${esc(e)}</code>`).join(', ')}. CloudEvents JSON, signed per <a href="https://www.standardwebhooks.com/">Standard Webhooks</a>; retried for about three days.</p>
${secret ? `<p>Signing secret, shown once:</p><div class="address"><code>${esc(secret)}</code><button class="copy" type="button" data-copy="${esc(secret)}">Copy</button></div>` : ''}
${hooks
.map(
(w) => `<div class="row"><code>${esc(w.url)}</code><span class="muted small">${esc(w.events.join(', '))}${w.enabled ? '' : ` · off: ${esc(w.disabled_reason ?? 'disabled')}`}</span>
${(['test', 'rotate', 'delete'] as const).map((a) => `<form method="post" action="/me/webhooks/${a}">${csrfField(ctx)}<input type="hidden" name="id" value="${esc(w.id)}"><button class="copy${a === 'delete' ? ' bad' : ''}">${a === 'test' ? 'Send a test' : a === 'rotate' ? 'New secret' : 'Remove'}</button></form>`).join('')}</div>`,
)
.join('')}
<form method="post" action="/me/webhooks/add">${csrfField(ctx)}<div class="row"><input name="url" type="url" required placeholder="https://example.com/webhooks/adnetwork" aria-label="Webhook URL"><input name="events" placeholder="Events (blank = all, or ad.*, deposit.completed)" aria-label="Events"><button class="btn">Add webhook</button></div></form>
<p class="muted small">Also from the CLI (<code>adnetwork webhooks</code>), MCP and <code>/api/v1/webhooks</code>.</p></div>`
}

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,
Expand All @@ -346,6 +368,7 @@ ${redeemCard(ctx)}
<div class="card"><h2>Invites</h2><p class="muted small">Hand out up to three invites. When someone new joins with one, you both get ad credit. <a href="/invites">Your invites</a></p></div>
<div class="card"><h2>API key</h2>${o.apiKey ? `<p>Your key, shown once:</p><div class="address"><code>${esc(o.apiKey)}</code><button class="copy" type="button" data-copy="${esc(o.apiKey)}">Copy</button></div><p class="muted small">export ADNETWORK_KEY=${esc(o.apiKey)}</p>` : `<p class="muted small">For the API, the CLI and MCP. ${u.apiKeyHash ? 'You have one; making a new one replaces it.' : ''}</p>`}
<form method="post" action="/me/key">${csrfField(ctx)}<button class="copy">${u.apiKeyHash ? 'Replace my key' : 'Make a key'}</button></form></div>
${webhooksCard(ctx, o.webhooks ?? [], o.webhookSecret)}
<details class="card"><summary>Delete my account</summary><form method="post" action="/me/delete">${csrfField(ctx)}<button class="copy bad">Stop my ads and sites and delete everything ad.network keeps</button></form><p class="muted small">Withdraw your earnings first. Unspent ad budget is not refunded automatically: ask on <a href="${REPO}/issues">GitHub</a>.</p></details>`,
})
}
Expand Down
11 changes: 11 additions & 0 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions src/rewards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id> or invite:<id>:<side>; 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
}
Expand Down
Loading
Loading