diff --git a/apps/web/app/api/bounties/[id]/claim/route.ts b/apps/web/app/api/bounties/[id]/claim/route.ts index 3a676f7..7d5c0e2 100644 --- a/apps/web/app/api/bounties/[id]/claim/route.ts +++ b/apps/web/app/api/bounties/[id]/claim/route.ts @@ -11,14 +11,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: if (!did) return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); const { id } = await params; - const bountyId = parseInt(id); const { coupon_id } = await req.json(); if (!coupon_id) return NextResponse.json({ error: 'coupon_id is required' }, { status: 400 }); const db = getDb(); - const rows = await db.sql`SELECT * FROM bounties WHERE id = ${bountyId}`; + const rows = await db.sql`SELECT * FROM bounties WHERE public_id = ${id}`; if (!rows.length) return NextResponse.json({ error: 'Bounty not found' }, { status: 404 }); const bounty = rows[0]; + const bountyId = bounty.id; if (!['open', 'funded'].includes(bounty.status)) { return NextResponse.json({ error: `Bounty is already ${bounty.status}` }, { status: 409 }); diff --git a/apps/web/app/api/bounties/[id]/route.ts b/apps/web/app/api/bounties/[id]/route.ts index f5a00a5..96aee2f 100644 --- a/apps/web/app/api/bounties/[id]/route.ts +++ b/apps/web/app/api/bounties/[id]/route.ts @@ -11,7 +11,7 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: FROM bounties b LEFT JOIN stores s ON s.id = b.store_id LEFT JOIN coupons c ON c.id = b.coupon_id - WHERE b.id = ${parseInt(id)} + WHERE b.public_id = ${id} `; if (!rows.length) return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json(rows[0]); diff --git a/apps/web/app/api/bounties/route.ts b/apps/web/app/api/bounties/route.ts index 63c697e..6562ebd 100644 --- a/apps/web/app/api/bounties/route.ts +++ b/apps/web/app/api/bounties/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDb } from '@/lib/db'; import { getSessionDid } from '@/lib/auth'; +import { generatePublicId } from '@/lib/id'; const COINPAY_BASE = 'https://coinpayportal.com'; const API_KEY = process.env.COINPAY_API_KEY!; @@ -36,63 +37,68 @@ export async function POST(req: NextRequest) { if (isNaN(reward) || reward < 0.10) return NextResponse.json({ error: 'Minimum reward is $0.10' }, { status: 400 }); if (!store_id && !store_name?.trim()) return NextResponse.json({ error: 'Store is required' }, { status: 400 }); - const db = getDb(); + try { + const db = getDb(); + const publicId = generatePublicId(); - // Insert bounty as 'open' first to get an ID - await db.sql` - INSERT INTO bounties (creator_did, store_id, store_name, title, description, reward_usd, status) - VALUES ( - ${did}, - ${store_id ?? null}, - ${store_name?.trim() ?? null}, - ${title.trim()}, - ${description?.trim() ?? null}, - ${reward}, - 'open' - ) - `; - const [{ id: bountyId }] = await db.sql` - SELECT id FROM bounties WHERE creator_did = ${did} ORDER BY id DESC LIMIT 1 - `; + // Insert bounty as 'open' with a non-sequential public id for URLs. + await db.sql` + INSERT INTO bounties (public_id, creator_did, store_id, store_name, title, description, reward_usd, status) + VALUES ( + ${publicId}, + ${did}, + ${store_id ?? null}, + ${store_name?.trim() ?? null}, + ${title.trim()}, + ${description?.trim() ?? null}, + ${reward}, + 'open' + ) + `; - // Create a CoinPay payment for the creator to fund the bounty - // Docs: POST /api/payments/create - let paymentAddress: string | null = null; - let paymentId: string | null = null; + // Create a CoinPay payment for the creator to fund the bounty + // Docs: POST /api/payments/create + let paymentAddress: string | null = null; + let paymentId: string | null = null; - try { - const res = await fetch(`${COINPAY_BASE}/api/payments/create`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_KEY}` }, - body: JSON.stringify({ - business_id: MERCHANT_ID, - amount_usd: reward, - currency: 'usdc_pol', // default to USDC on Polygon — low fees - description: `Coupon bounty: ${title.trim()}`, - redirect_url: `${APP_URL}/bounties/${bountyId}?funded=1`, - metadata: { type: 'bounty_fund', bounty_id: bountyId, creator_did: did }, - }), - }); + try { + const res = await fetch(`${COINPAY_BASE}/api/payments/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_KEY}` }, + body: JSON.stringify({ + business_id: MERCHANT_ID, + amount_usd: reward, + currency: 'usdc_pol', // default to USDC on Polygon — low fees + description: `Coupon bounty: ${title.trim()}`, + redirect_url: `${APP_URL}/bounties/${publicId}?funded=1`, + metadata: { type: 'bounty_fund', bounty_id: publicId, creator_did: did }, + }), + }); - if (res.ok) { - const data = await res.json(); - paymentId = data.payment_id ?? data.id ?? null; - paymentAddress = data.payment_address ?? null; - if (paymentId) { - await db.sql`UPDATE bounties SET payment_id = ${paymentId} WHERE id = ${bountyId}`; + if (res.ok) { + const data = await res.json(); + paymentId = data.payment_id ?? data.id ?? null; + paymentAddress = data.payment_address ?? null; + if (paymentId) { + await db.sql`UPDATE bounties SET payment_id = ${paymentId} WHERE public_id = ${publicId}`; + } + } else { + const err = await res.text(); + console.error('CoinPay payment create error:', err); } - } else { - const err = await res.text(); - console.error('CoinPay payment create error:', err); + } catch (e) { + console.error('CoinPay payment create failed:', e); } + + return NextResponse.json({ + id: publicId, + public_id: publicId, + payment_id: paymentId, + payment_address: paymentAddress, + pay_url: paymentId ? `${COINPAY_BASE}/pay/${paymentId}` : null, + }, { status: 201 }); } catch (e) { - console.error('CoinPay payment create failed:', e); + console.error('Failed to create bounty:', e); + return NextResponse.json({ error: 'Failed to create bounty. Please try again.' }, { status: 500 }); } - - return NextResponse.json({ - id: bountyId, - payment_id: paymentId, - payment_address: paymentAddress, - pay_url: paymentId ? `${COINPAY_BASE}/pay/${paymentId}` : null, - }, { status: 201 }); } diff --git a/apps/web/app/api/webhooks/coinpay/route.ts b/apps/web/app/api/webhooks/coinpay/route.ts index e604525..b45e60b 100644 --- a/apps/web/app/api/webhooks/coinpay/route.ts +++ b/apps/web/app/api/webhooks/coinpay/route.ts @@ -81,7 +81,7 @@ export async function POST(req: NextRequest) { UPDATE bounties SET status = 'funded', payment_id = ${event.data?.payment_id ?? event.id}, updated_at = ${new Date().toISOString()} - WHERE id = ${meta.bounty_id} AND status = 'open' + WHERE public_id = ${meta.bounty_id} AND status = 'open' `; console.log('Bounty funded:', meta.bounty_id); } diff --git a/apps/web/app/bounties/[id]/BountyClaim.tsx b/apps/web/app/bounties/[id]/BountyClaim.tsx index 69a326b..c695a73 100644 --- a/apps/web/app/bounties/[id]/BountyClaim.tsx +++ b/apps/web/app/bounties/[id]/BountyClaim.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'; interface Coupon { id: number; title: string; code: string | null; } -export default function BountyClaim({ bountyId, storeId }: { bountyId: number; storeId: number | null }) { +export default function BountyClaim({ bountyPublicId, storeId }: { bountyPublicId: string; storeId: number | null }) { const router = useRouter(); const [coupons, setCoupons] = useState([]); const [selectedId, setSelectedId] = useState(''); @@ -23,7 +23,7 @@ export default function BountyClaim({ bountyId, storeId }: { bountyId: number; s setError(''); setLoading(true); try { - const res = await fetch(`/api/bounties/${bountyId}/claim`, { + const res = await fetch(`/api/bounties/${bountyPublicId}/claim`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ coupon_id: parseInt(selectedId) }), diff --git a/apps/web/app/bounties/[id]/page.tsx b/apps/web/app/bounties/[id]/page.tsx index 751d7c6..d4f0ba2 100644 --- a/apps/web/app/bounties/[id]/page.tsx +++ b/apps/web/app/bounties/[id]/page.tsx @@ -9,6 +9,7 @@ import BountyClaim from './BountyClaim'; interface Bounty { id: number; + public_id: string; title: string; description: string | null; reward_usd: number; @@ -24,24 +25,25 @@ interface Bounty { created_at: string; } -async function getBounty(id: number): Promise { - try { - const db = getDb(); - const rows = await db.sql` - SELECT b.*, COALESCE(s.name, b.store_name) AS display_store, - c.code AS coupon_code, c.title AS coupon_title - FROM bounties b - LEFT JOIN stores s ON s.id = b.store_id - LEFT JOIN coupons c ON c.id = b.coupon_id - WHERE b.id = ${id} - `; - return rows.length ? rows[0] : null; - } catch { return null; } +// Look up by public_id. We intentionally do NOT swallow DB errors here: a +// thrown query (e.g. paused DB node) must surface as an error, not a 404 — +// otherwise a real, existing bounty gets reported as "not found". +async function getBounty(publicId: string): Promise { + const db = getDb(); + const rows = await db.sql` + SELECT b.*, COALESCE(s.name, b.store_name) AS display_store, + c.code AS coupon_code, c.title AS coupon_title + FROM bounties b + LEFT JOIN stores s ON s.id = b.store_id + LEFT JOIN coupons c ON c.id = b.coupon_id + WHERE b.public_id = ${publicId} + `; + return rows.length ? rows[0] : null; } export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { const { id } = await params; - const bounty = await getBounty(parseInt(id)); + const bounty = await getBounty(id); if (!bounty) return { title: 'Bounty not found' }; return { title: `Bounty: ${bounty.title}`, @@ -61,7 +63,7 @@ const COINPAY_BASE = 'https://coinpayportal.com'; export default async function BountyPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; - const [bounty, did] = await Promise.all([getBounty(parseInt(id)), getSessionDid()]); + const [bounty, did] = await Promise.all([getBounty(id), getSessionDid()]); if (!bounty) notFound(); const status = STATUS_LABELS[bounty.status] ?? { label: bounty.status, color: 'bg-gray-100 text-gray-600 border-gray-200' }; @@ -132,12 +134,12 @@ export default async function BountyPage({ params }: { params: Promise<{ id: str )} {canClaim && ( - + )} {!did && ['open', 'funded'].includes(bounty.status) && ( Connect with CoinPay to claim diff --git a/apps/web/app/bounties/new/page.tsx b/apps/web/app/bounties/new/page.tsx index 929c56f..a04a803 100644 --- a/apps/web/app/bounties/new/page.tsx +++ b/apps/web/app/bounties/new/page.tsx @@ -50,9 +50,9 @@ export default function NewBountyPage() { }); const data = await res.json(); if (!res.ok) { setError(data.error ?? 'Failed to create bounty'); return; } - // If CoinPay returned a checkout URL, redirect there to fund the bounty - if (data.checkout_url) { - window.location.href = data.checkout_url; + // If CoinPay returned a payment URL, redirect there to fund the bounty + if (data.pay_url) { + window.location.href = data.pay_url; } else { router.push(`/bounties/${data.id}`); } diff --git a/apps/web/app/bounties/page.tsx b/apps/web/app/bounties/page.tsx index fa686f9..50cb3a0 100644 --- a/apps/web/app/bounties/page.tsx +++ b/apps/web/app/bounties/page.tsx @@ -13,6 +13,7 @@ export const metadata: Metadata = { interface Bounty { id: number; + public_id: string; title: string; description: string | null; reward_usd: number; @@ -175,7 +176,7 @@ export default async function BountiesPage() { {bounties.map((b) => (
diff --git a/apps/web/lib/id.ts b/apps/web/lib/id.ts new file mode 100644 index 0000000..a846121 --- /dev/null +++ b/apps/web/lib/id.ts @@ -0,0 +1,19 @@ +import { randomBytes } from 'node:crypto'; + +// URL-safe, non-sequential public identifiers for resources whose integer +// primary keys should not be exposed in URLs (enumeration / count leakage). +const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +/** + * Generate a short, URL-safe, non-sequential id (base62, ~71 bits at len 12). + * Collision probability is negligible at our scale; callers should still rely + * on a UNIQUE constraint as the source of truth. + */ +export function generatePublicId(length = 12): string { + const bytes = randomBytes(length); + let id = ''; + for (let i = 0; i < length; i++) { + id += ALPHABET[bytes[i] % ALPHABET.length]; + } + return id; +} diff --git a/apps/web/scripts/migrate.mjs b/apps/web/scripts/migrate.mjs index 01a6fa7..37ae68d 100644 --- a/apps/web/scripts/migrate.mjs +++ b/apps/web/scripts/migrate.mjs @@ -8,6 +8,16 @@ import { Database } from '@sqlitecloud/drivers'; import { readFileSync } from 'fs'; import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; +import { randomBytes } from 'node:crypto'; + +// Mirror of apps/web/lib/id.ts — short, URL-safe, non-sequential public ids. +const ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; +function generatePublicId(length = 12) { + const bytes = randomBytes(length); + let id = ''; + for (let i = 0; i < length; i++) id += ID_ALPHABET[bytes[i] % ID_ALPHABET.length]; + return id; +} const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -117,6 +127,7 @@ console.log(' blog_posts'); await db.sql` CREATE TABLE IF NOT EXISTS bounties ( id INTEGER PRIMARY KEY AUTOINCREMENT, + public_id TEXT, creator_did TEXT NOT NULL, store_id INTEGER REFERENCES stores(id), store_name TEXT, @@ -131,6 +142,15 @@ await db.sql` updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `; +// public_id: non-sequential URL identifier (added after initial release). +await addColumn(() => db.sql`ALTER TABLE bounties ADD COLUMN public_id TEXT`); +// Backfill any rows missing a public_id (existing bounties created pre-migration). +const needIds = await db.sql`SELECT id FROM bounties WHERE public_id IS NULL OR public_id = ''`; +for (const row of needIds) { + await db.sql`UPDATE bounties SET public_id = ${generatePublicId()} WHERE id = ${row.id}`; +} +if (needIds.length) console.log(` bounties: backfilled ${needIds.length} public_id(s)`); +await db.sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_bounties_public_id ON bounties(public_id)`; console.log(' bounties'); const [{ n }] = await db.sql`SELECT COUNT(*) AS n FROM stores`;