Skip to content

Commit 5e47b16

Browse files
ralyodioclaude
andcommitted
feat: use non-sequential public ids for bounty URLs; fix 404 on DB error
Bounty URLs exposed the sequential integer primary key (/bounties/1), which is enumerable and leaks the total count. Switch all public bounty URLs to a non-sequential, URL-safe public_id while keeping the integer PK internally for joins/FKs. - lib/id.ts: generatePublicId() (base62 via crypto), mirrored in migrate.mjs for backfill. - Migration: add bounties.public_id, backfill existing rows, add a UNIQUE index. - POST /api/bounties generates a public_id and returns it; CoinPay redirect_url + metadata.bounty_id and the funding webhook now key off public_id. - Detail page, GET route, claim route, listing links, and auth returnTo all resolve by public_id. - Fix the 404 bug: the detail page's getBounty swallowed DB errors and returned null, so a paused/erroring DB made a real bounty render as 404. Let query errors surface instead of masquerading as not-found. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3308272 commit 5e47b16

9 files changed

Lines changed: 76 additions & 33 deletions

File tree

apps/web/app/api/bounties/[id]/claim/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
1111
if (!did) return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
1212

1313
const { id } = await params;
14-
const bountyId = parseInt(id);
1514
const { coupon_id } = await req.json();
1615
if (!coupon_id) return NextResponse.json({ error: 'coupon_id is required' }, { status: 400 });
1716

1817
const db = getDb();
19-
const rows = await db.sql`SELECT * FROM bounties WHERE id = ${bountyId}`;
18+
const rows = await db.sql`SELECT * FROM bounties WHERE public_id = ${id}`;
2019
if (!rows.length) return NextResponse.json({ error: 'Bounty not found' }, { status: 404 });
2120
const bounty = rows[0];
21+
const bountyId = bounty.id;
2222

2323
if (!['open', 'funded'].includes(bounty.status)) {
2424
return NextResponse.json({ error: `Bounty is already ${bounty.status}` }, { status: 409 });

apps/web/app/api/bounties/[id]/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id:
1111
FROM bounties b
1212
LEFT JOIN stores s ON s.id = b.store_id
1313
LEFT JOIN coupons c ON c.id = b.coupon_id
14-
WHERE b.id = ${parseInt(id)}
14+
WHERE b.public_id = ${id}
1515
`;
1616
if (!rows.length) return NextResponse.json({ error: 'Not found' }, { status: 404 });
1717
return NextResponse.json(rows[0]);

apps/web/app/api/bounties/route.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextRequest, NextResponse } from 'next/server';
22
import { getDb } from '@/lib/db';
33
import { getSessionDid } from '@/lib/auth';
4+
import { generatePublicId } from '@/lib/id';
45

56
const COINPAY_BASE = 'https://coinpayportal.com';
67
const API_KEY = process.env.COINPAY_API_KEY!;
@@ -38,11 +39,13 @@ export async function POST(req: NextRequest) {
3839

3940
try {
4041
const db = getDb();
42+
const publicId = generatePublicId();
4143

42-
// Insert bounty as 'open' first to get an ID
44+
// Insert bounty as 'open' with a non-sequential public id for URLs.
4345
await db.sql`
44-
INSERT INTO bounties (creator_did, store_id, store_name, title, description, reward_usd, status)
46+
INSERT INTO bounties (public_id, creator_did, store_id, store_name, title, description, reward_usd, status)
4547
VALUES (
48+
${publicId},
4649
${did},
4750
${store_id ?? null},
4851
${store_name?.trim() ?? null},
@@ -52,9 +55,6 @@ export async function POST(req: NextRequest) {
5255
'open'
5356
)
5457
`;
55-
const [{ id: bountyId }] = await db.sql`
56-
SELECT id FROM bounties WHERE creator_did = ${did} ORDER BY id DESC LIMIT 1
57-
`;
5858

5959
// Create a CoinPay payment for the creator to fund the bounty
6060
// Docs: POST /api/payments/create
@@ -70,8 +70,8 @@ export async function POST(req: NextRequest) {
7070
amount_usd: reward,
7171
currency: 'usdc_pol', // default to USDC on Polygon — low fees
7272
description: `Coupon bounty: ${title.trim()}`,
73-
redirect_url: `${APP_URL}/bounties/${bountyId}?funded=1`,
74-
metadata: { type: 'bounty_fund', bounty_id: bountyId, creator_did: did },
73+
redirect_url: `${APP_URL}/bounties/${publicId}?funded=1`,
74+
metadata: { type: 'bounty_fund', bounty_id: publicId, creator_did: did },
7575
}),
7676
});
7777

@@ -80,7 +80,7 @@ export async function POST(req: NextRequest) {
8080
paymentId = data.payment_id ?? data.id ?? null;
8181
paymentAddress = data.payment_address ?? null;
8282
if (paymentId) {
83-
await db.sql`UPDATE bounties SET payment_id = ${paymentId} WHERE id = ${bountyId}`;
83+
await db.sql`UPDATE bounties SET payment_id = ${paymentId} WHERE public_id = ${publicId}`;
8484
}
8585
} else {
8686
const err = await res.text();
@@ -91,7 +91,8 @@ export async function POST(req: NextRequest) {
9191
}
9292

9393
return NextResponse.json({
94-
id: bountyId,
94+
id: publicId,
95+
public_id: publicId,
9596
payment_id: paymentId,
9697
payment_address: paymentAddress,
9798
pay_url: paymentId ? `${COINPAY_BASE}/pay/${paymentId}` : null,

apps/web/app/api/webhooks/coinpay/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export async function POST(req: NextRequest) {
8181
UPDATE bounties
8282
SET status = 'funded', payment_id = ${event.data?.payment_id ?? event.id},
8383
updated_at = ${new Date().toISOString()}
84-
WHERE id = ${meta.bounty_id} AND status = 'open'
84+
WHERE public_id = ${meta.bounty_id} AND status = 'open'
8585
`;
8686
console.log('Bounty funded:', meta.bounty_id);
8787
}

apps/web/app/bounties/[id]/BountyClaim.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
55

66
interface Coupon { id: number; title: string; code: string | null; }
77

8-
export default function BountyClaim({ bountyId, storeId }: { bountyId: number; storeId: number | null }) {
8+
export default function BountyClaim({ bountyPublicId, storeId }: { bountyPublicId: string; storeId: number | null }) {
99
const router = useRouter();
1010
const [coupons, setCoupons] = useState<Coupon[]>([]);
1111
const [selectedId, setSelectedId] = useState('');
@@ -23,7 +23,7 @@ export default function BountyClaim({ bountyId, storeId }: { bountyId: number; s
2323
setError('');
2424
setLoading(true);
2525
try {
26-
const res = await fetch(`/api/bounties/${bountyId}/claim`, {
26+
const res = await fetch(`/api/bounties/${bountyPublicId}/claim`, {
2727
method: 'POST',
2828
headers: { 'Content-Type': 'application/json' },
2929
body: JSON.stringify({ coupon_id: parseInt(selectedId) }),

apps/web/app/bounties/[id]/page.tsx

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import BountyClaim from './BountyClaim';
99

1010
interface Bounty {
1111
id: number;
12+
public_id: string;
1213
title: string;
1314
description: string | null;
1415
reward_usd: number;
@@ -24,24 +25,25 @@ interface Bounty {
2425
created_at: string;
2526
}
2627

27-
async function getBounty(id: number): Promise<Bounty | null> {
28-
try {
29-
const db = getDb();
30-
const rows = await db.sql`
31-
SELECT b.*, COALESCE(s.name, b.store_name) AS display_store,
32-
c.code AS coupon_code, c.title AS coupon_title
33-
FROM bounties b
34-
LEFT JOIN stores s ON s.id = b.store_id
35-
LEFT JOIN coupons c ON c.id = b.coupon_id
36-
WHERE b.id = ${id}
37-
`;
38-
return rows.length ? rows[0] : null;
39-
} catch { return null; }
28+
// Look up by public_id. We intentionally do NOT swallow DB errors here: a
29+
// thrown query (e.g. paused DB node) must surface as an error, not a 404 —
30+
// otherwise a real, existing bounty gets reported as "not found".
31+
async function getBounty(publicId: string): Promise<Bounty | null> {
32+
const db = getDb();
33+
const rows = await db.sql`
34+
SELECT b.*, COALESCE(s.name, b.store_name) AS display_store,
35+
c.code AS coupon_code, c.title AS coupon_title
36+
FROM bounties b
37+
LEFT JOIN stores s ON s.id = b.store_id
38+
LEFT JOIN coupons c ON c.id = b.coupon_id
39+
WHERE b.public_id = ${publicId}
40+
`;
41+
return rows.length ? rows[0] : null;
4042
}
4143

4244
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
4345
const { id } = await params;
44-
const bounty = await getBounty(parseInt(id));
46+
const bounty = await getBounty(id);
4547
if (!bounty) return { title: 'Bounty not found' };
4648
return {
4749
title: `Bounty: ${bounty.title}`,
@@ -61,7 +63,7 @@ const COINPAY_BASE = 'https://coinpayportal.com';
6163

6264
export default async function BountyPage({ params }: { params: Promise<{ id: string }> }) {
6365
const { id } = await params;
64-
const [bounty, did] = await Promise.all([getBounty(parseInt(id)), getSessionDid()]);
66+
const [bounty, did] = await Promise.all([getBounty(id), getSessionDid()]);
6567
if (!bounty) notFound();
6668

6769
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
132134
)}
133135

134136
{canClaim && (
135-
<BountyClaim bountyId={bounty.id} storeId={bounty.store_id} />
137+
<BountyClaim bountyPublicId={bounty.public_id} storeId={bounty.store_id} />
136138
)}
137139

138140
{!did && ['open', 'funded'].includes(bounty.status) && (
139141
<a
140-
href={`/api/auth/coinpay?returnTo=/bounties/${bounty.id}`}
142+
href={`/api/auth/coinpay?returnTo=/bounties/${bounty.public_id}`}
141143
className="w-full text-center bg-gray-900 hover:bg-gray-700 text-white font-semibold px-6 py-3 rounded-xl transition-colors"
142144
>
143145
Connect with CoinPay to claim

apps/web/app/bounties/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const metadata: Metadata = {
1313

1414
interface Bounty {
1515
id: number;
16+
public_id: string;
1617
title: string;
1718
description: string | null;
1819
reward_usd: number;
@@ -175,7 +176,7 @@ export default async function BountiesPage() {
175176
{bounties.map((b) => (
176177
<Link
177178
key={b.id}
178-
href={`/bounties/${b.id}`}
179+
href={`/bounties/${b.public_id}`}
179180
className="group border border-gray-200 rounded-xl p-5 flex items-center gap-4 hover:border-orange-300 hover:shadow-lg hover:shadow-orange-50 transition-all"
180181
>
181182
<div className="flex-1 min-w-0">

apps/web/lib/id.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { randomBytes } from 'node:crypto';
2+
3+
// URL-safe, non-sequential public identifiers for resources whose integer
4+
// primary keys should not be exposed in URLs (enumeration / count leakage).
5+
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
6+
7+
/**
8+
* Generate a short, URL-safe, non-sequential id (base62, ~71 bits at len 12).
9+
* Collision probability is negligible at our scale; callers should still rely
10+
* on a UNIQUE constraint as the source of truth.
11+
*/
12+
export function generatePublicId(length = 12): string {
13+
const bytes = randomBytes(length);
14+
let id = '';
15+
for (let i = 0; i < length; i++) {
16+
id += ALPHABET[bytes[i] % ALPHABET.length];
17+
}
18+
return id;
19+
}

apps/web/scripts/migrate.mjs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ import { Database } from '@sqlitecloud/drivers';
88
import { readFileSync } from 'fs';
99
import { resolve, dirname } from 'path';
1010
import { fileURLToPath } from 'url';
11+
import { randomBytes } from 'node:crypto';
12+
13+
// Mirror of apps/web/lib/id.ts — short, URL-safe, non-sequential public ids.
14+
const ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
15+
function generatePublicId(length = 12) {
16+
const bytes = randomBytes(length);
17+
let id = '';
18+
for (let i = 0; i < length; i++) id += ID_ALPHABET[bytes[i] % ID_ALPHABET.length];
19+
return id;
20+
}
1121

1222
const __dirname = dirname(fileURLToPath(import.meta.url));
1323

@@ -117,6 +127,7 @@ console.log(' blog_posts');
117127
await db.sql`
118128
CREATE TABLE IF NOT EXISTS bounties (
119129
id INTEGER PRIMARY KEY AUTOINCREMENT,
130+
public_id TEXT,
120131
creator_did TEXT NOT NULL,
121132
store_id INTEGER REFERENCES stores(id),
122133
store_name TEXT,
@@ -131,6 +142,15 @@ await db.sql`
131142
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
132143
)
133144
`;
145+
// public_id: non-sequential URL identifier (added after initial release).
146+
await addColumn(() => db.sql`ALTER TABLE bounties ADD COLUMN public_id TEXT`);
147+
// Backfill any rows missing a public_id (existing bounties created pre-migration).
148+
const needIds = await db.sql`SELECT id FROM bounties WHERE public_id IS NULL OR public_id = ''`;
149+
for (const row of needIds) {
150+
await db.sql`UPDATE bounties SET public_id = ${generatePublicId()} WHERE id = ${row.id}`;
151+
}
152+
if (needIds.length) console.log(` bounties: backfilled ${needIds.length} public_id(s)`);
153+
await db.sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_bounties_public_id ON bounties(public_id)`;
134154
console.log(' bounties');
135155

136156
const [{ n }] = await db.sql`SELECT COUNT(*) AS n FROM stores`;

0 commit comments

Comments
 (0)