diff --git a/README.md b/README.md index 516ad28..c5aed73 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ ADNETWORK_KEY=$(openssl rand -hex 32) ADNETWORK_COINPAY_CLIENT_ID=cp_… \ ADNETWORK_ADVERTISER_TOKEN=crp_… ADNETWORK_PUBLISHER_TOKEN=crp_… bun src/server.ts ``` -Settings: `ADNETWORK_KEY` (seals CoinPay tokens, signs claim codes), +Settings: `ADNETWORK_KEY` (seals sign-in state, signs claim codes; no CoinPay token is stored: sign-in reads your payout wallets once and drops it), `ADNETWORK_COINPAY_CLIENT_ID` / `_SECRET` (CoinPay OAuth client, scopes `openid profile email wallet:read`), `ADNETWORK_ADVERTISER_TOKEN` / `ADNETWORK_PUBLISHER_TOKEN` (crawlproof API tokens; the advertiser account must be diff --git a/package.json b/package.json index 3344616..9e1d72a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/adnetwork", - "version": "0.3.0", + "version": "0.3.1", "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", diff --git a/src/db.ts b/src/db.ts index ad710f1..4801596 100644 --- a/src/db.ts +++ b/src/db.ts @@ -4,7 +4,13 @@ * A person is their CoinPay account (`sub`). crawlproof holds the campaigns, * slots and money; we keep which of them are whose, the deposits each * advertiser made, and the last numbers we read, so a page never waits on - * crawlproof to say who owns what. CoinPay tokens are sealed with ADNETWORK_KEY. + * crawlproof to say who owns what. + * + * No CoinPay token is kept (unlock-on-use fleet rule, 2026-10-28): the only + * use was suggesting the person's own payout wallets, so sign-in reads those + * addresses once with the fresh access token and drops it. `users.tokens` is + * the legacy copy under ADNETWORK_KEY: never read, NULLed on the next sign-in, + * counted by legacyTokenCount() until the cutoff. */ import { Database } from 'bun:sqlite' import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto' @@ -155,6 +161,8 @@ export class Store { ); `) // Added with hosted checkout sessions; older databases lack the column. + const userCols = new Set((this.db.query('PRAGMA table_info(users)').all() as { name: string }[]).map((c) => c.name)) + if (!userCols.has('wallet_suggestions')) this.db.exec("ALTER TABLE users ADD COLUMN wallet_suggestions TEXT NOT NULL DEFAULT '[]'") const cols = (this.db.query('PRAGMA table_info(deposits)').all() as { name: string }[]).map((c) => c.name) if (!cols.includes('checkout_url')) this.db.exec("ALTER TABLE deposits ADD COLUMN checkout_url TEXT NOT NULL DEFAULT ''") } @@ -165,13 +173,14 @@ export class Store { return row ? { sub: row.sub, name: row.name, email: row.email, apiKeyHash: row.api_key_hash, createdAt: row.created_at } : null } - upsertUser(sub: string, name: string, email: string, tokens: Tokens, now = Date.now()): User { + /** Creates or refreshes the account. A sign-in also drops any legacy token copy. */ + upsertUser(sub: string, name: string, email: string, now = Date.now()): User { this.db .query( - `INSERT INTO users (sub, name, email, tokens, created_at) VALUES (?, ?, ?, ?, ?) - ON CONFLICT(sub) DO UPDATE SET name = excluded.name, email = excluded.email, tokens = excluded.tokens`, + `INSERT INTO users (sub, name, email, created_at) VALUES (?, ?, ?, ?) + ON CONFLICT(sub) DO UPDATE SET name = excluded.name, email = excluded.email, tokens = NULL`, ) - .run(sub, name, email, this.sealer.seal(JSON.stringify(tokens)), now) + .run(sub, name, email, now) return this.user(sub)! } @@ -187,13 +196,23 @@ export class Store { this.db.query('UPDATE users SET api_key_hash = ? WHERE sub = ?').run(hashKey(key), sub) } - tokens(sub: string): Tokens | null { - const row: any = this.db.query('SELECT tokens FROM users WHERE sub = ?').get(sub) - return row?.tokens ? JSON.parse(this.sealer.open(row.tokens)) : null + /** Payout wallets read from CoinPay at sign-in (public addresses, not credentials). */ + walletSuggestions(sub: string): { coin: string; address: string }[] { + const row: any = this.db.query('SELECT wallet_suggestions FROM users WHERE sub = ?').get(sub) + try { + return row?.wallet_suggestions ? JSON.parse(row.wallet_suggestions) : [] + } catch { + return [] + } + } + + setWalletSuggestions(sub: string, list: { coin: string; address: string }[]): void { + this.db.query('UPDATE users SET wallet_suggestions = ? WHERE sub = ?').run(JSON.stringify(list), sub) } - saveTokens(sub: string, tokens: Tokens): void { - this.db.query('UPDATE users SET tokens = ? WHERE sub = ?').run(this.sealer.seal(JSON.stringify(tokens)), sub) + /** Accounts still holding a legacy ADNETWORK_KEY token copy (cleared at their next sign-in). */ + legacyTokenCount(): number { + return (this.db.query('SELECT COUNT(*) AS n FROM users WHERE tokens IS NOT NULL').get() as { n: number }).n } /** Forget a person. Their campaigns and slots stay on crawlproof; the caller stops them first. */ diff --git a/src/html.ts b/src/html.ts index e4f94b2..f1e7360 100644 --- a/src/html.ts +++ b/src/html.ts @@ -174,7 +174,7 @@ 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.

+

Your data

We keep your CoinPay name and email, the payout wallets CoinPay listed when you signed in (to prefill forms; never your CoinPay sign-in itself, which we do not store), 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.

`, }) } diff --git a/src/server.ts b/src/server.ts index 3420096..58760db 100644 --- a/src/server.ts +++ b/src/server.ts @@ -150,7 +150,9 @@ export function createApp(deps: Deps) { try { const tokens = await exchangeCode({ code, verifier: saved.verifier, clientId: config.clientId, ...(config.clientSecret ? { clientSecret: config.clientSecret } : {}), redirectUri: saved.redirectUri, base: config.coinpayBase, ...(config.fetch ? { fetch: config.fetch } : {}) }) const me = await new CoinPay({ base: config.coinpayBase, token: tokens.accessToken, ...(config.fetch ? { fetch: config.fetch } : {}) }).userinfo() - const user = store.upsertUser(me.sub, me.name ?? '', me.email ?? '', tokens) + const user = store.upsertUser(me.sub, me.name ?? '', me.email ?? '') + // The token's only job: read their payout wallets now. It is then dropped, never stored. + await net.captureWalletSuggestions(me.sub, tokens.accessToken) 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. diff --git a/src/service.ts b/src/service.ts index d0b6dae..2709767 100644 --- a/src/service.ts +++ b/src/service.ts @@ -18,7 +18,6 @@ import { createHmac, randomBytes } from 'node:crypto' 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' @@ -184,7 +183,6 @@ export class AdNetwork { readonly pubs: Crawlproof /** Customer webhooks (@profullstack/webhooks): endpoints per account, durable delivery. */ readonly hooks: Webhooks - private refreshing = new Map>() constructor( readonly store: Store, @@ -217,42 +215,30 @@ export class AdNetwork { // ---- CoinPay, as the person (only to suggest their own payout wallet) ---- - clientFor(sub: string): Promise { - const pending = this.refreshing.get(sub) - if (pending) return pending - const tokens = this.store.tokens(sub) - if (!tokens) return Promise.reject(new UserError('Sign in with CoinPay again.')) - const make = (token: string) => new CoinPay({ base: this.config.coinpayBase, token, ...(this.config.fetch ? { fetch: this.config.fetch } : {}) }) - if (tokens.expiresAt - Date.now() > 60_000) return Promise.resolve(make(tokens.accessToken)) - if (!tokens.refreshToken) return Promise.reject(new UserError('Your CoinPay sign-in has expired. Sign in again.')) - const job = refreshTokens({ - refreshToken: tokens.refreshToken, - clientId: this.config.clientId, - ...(this.config.clientSecret ? { clientSecret: this.config.clientSecret } : {}), - base: this.config.coinpayBase, - ...(this.config.fetch ? { fetch: this.config.fetch } : {}), - }) - .then((next) => { - this.store.saveTokens(sub, { accessToken: next.accessToken, refreshToken: next.refreshToken ?? tokens.refreshToken, expiresAt: next.expiresAt }) - return make(next.accessToken) - }) - .finally(() => this.refreshing.delete(sub)) - this.refreshing.set(sub, job) - return job - } - - /** Their CoinPay payout wallets in coins crawlproof pays out in, to prefill the form. */ - async walletSuggestions(sub: string): Promise<{ coin: string; address: string }[]> { + /** + * Read their CoinPay payout wallets once, at sign-in, with the fresh access + * token, and keep only the addresses. The token is not stored anywhere. + */ + async captureWalletSuggestions(sub: string, accessToken: string): Promise { try { - const wallets = await (await this.clientFor(sub)).wallets('account') - return wallets - .map((w) => ({ coin: String(w.cryptocurrency ?? '').toLowerCase(), address: String(w.address ?? w.wallet_address ?? '') })) - .filter((w) => (PAYOUT_COINS as readonly string[]).includes(w.coin) && w.address) + const client = new CoinPay({ base: this.config.coinpayBase, token: accessToken, ...(this.config.fetch ? { fetch: this.config.fetch } : {}) }) + const wallets = await client.wallets('account') + this.store.setWalletSuggestions( + sub, + wallets + .map((w) => ({ coin: String(w.cryptocurrency ?? '').toLowerCase(), address: String(w.address ?? w.wallet_address ?? '') })) + .filter((w) => (PAYOUT_COINS as readonly string[]).includes(w.coin) && w.address), + ) } catch { - return [] + // A prefill only: never in the way of signing in. } } + /** Their CoinPay payout wallets in coins crawlproof pays out in, to prefill the form (as of their last sign-in). */ + async walletSuggestions(sub: string): Promise<{ coin: string; address: string }[]> { + return this.store.walletSuggestions(sub) + } + // ---- advertisers ---------------------------------------------------------- balanceCents(sub: string): number { diff --git a/src/version.ts b/src/version.ts index 1eb951b..791fc7b 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.3.0' +export const VERSION = '0.3.1' diff --git a/test/app.test.ts b/test/app.test.ts index 7891dd2..5ca4849 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -181,6 +181,23 @@ describe('ad.network', () => { expect((await t.req(`/auth/callback?code=c1&state=${encodeURIComponent(loc.searchParams.get('state')!)}`)).status).toBe(400) }) + test('no CoinPay token is stored: sign-in reads payout wallets once and drops the token; a legacy copy is cleared', async () => { + const t = setup() + // A row as the pre-2026-10-28 code left it. + t.store.upsertUser('u-alice', 'Alice', 'a@example.com') + t.store.db.query("UPDATE users SET tokens = 'legacy-sealed-blob'").run() + expect(t.store.legacyTokenCount()).toBe(1) + await t.signIn() + const rows = t.store.db.query('SELECT * FROM users').all() as Record[] + for (const row of rows) expect(row.tokens).toBeNull() + const dump = JSON.stringify(rows) + expect(dump).not.toContain('"at"') + expect(dump).not.toContain('"rt"') + expect(t.store.legacyTokenCount()).toBe(0) + const sub = (rows.find((r) => r.wallet_suggestions !== '[]') ?? rows[0])!.sub as string + expect(t.store.walletSuggestions(sub)).toEqual([{ coin: 'usdc_pol', address: '0xAliceWallet000000000000000000000000000001' }]) + }) + test('free launch: an ad runs with no balance, and the pages say it is free', async () => { const t = setup({ freeAds: true }) expect(await (await t.req('/')).text()).toContain('Free while we launch') @@ -403,8 +420,8 @@ describe('ad.network', () => { test('house accounts run every property: no cap on sites', async () => { const t = setup() const tokens = { accessToken: 'a', refreshToken: 'r', expiresAt: Date.now() + 3600_000 } - const alice = t.store.upsertUser('u-alice', 'Alice', 'a@x.test', tokens) - const house = t.store.upsertUser('u-house', 'House', 'h@x.test', tokens) + const alice = t.store.upsertUser('u-alice', 'Alice', 'a@x.test') + const house = t.store.upsertUser('u-house', 'House', 'h@x.test') for (let i = 0; i < 25; i++) { await t.net.addSite(alice, `a${i}.example.org`) await t.net.addSite(house, `h${i}.example.org`) @@ -544,8 +561,8 @@ describe('customer webhooks', () => { 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.upsertUser('u-alice', 'Alice', 'a@example.com') + t.store.upsertUser('u-bob', 'Bob', 'b@example.com') t.store.setApiKey('u-alice', 'an_alice') t.store.setApiKey('u-bob', 'an_bob') const call = (key: string, method: string, path: string, body?: unknown) =>