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.
`,
})
}
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) =>