From a1566bfe5e4d92dc852af3be87e42c970dd63da6 Mon Sep 17 00:00:00 2001 From: Genesis Date: Thu, 19 Feb 2026 19:17:22 -0500 Subject: [PATCH 1/6] feat(api): add GET /bounties/:id endpoint --- packages/api/src/__tests__/app.test.ts | 58 +++++++++++++++- packages/api/src/app.ts | 96 +++++++++++++++++++++++++- packages/api/src/index.ts | 3 +- 3 files changed, 153 insertions(+), 4 deletions(-) diff --git a/packages/api/src/__tests__/app.test.ts b/packages/api/src/__tests__/app.test.ts index b4cf75f..3c66faf 100644 --- a/packages/api/src/__tests__/app.test.ts +++ b/packages/api/src/__tests__/app.test.ts @@ -1,11 +1,15 @@ import { describe, it, expect, beforeAll } from 'vitest'; -import { createApp } from '../app'; +import { createApp, type DbLike } from '../app'; describe('API App', () => { let app: ReturnType; + const mockDb: DbLike = { + execute: async () => ({ rows: [] }), + }; + beforeAll(() => { - app = createApp(); + app = createApp({ db: mockDb }); }); // ── Health Endpoint ────────────────────────────────────────────── @@ -75,4 +79,54 @@ describe('API App', () => { expect(res.status).toBe(400); }); }); + + // ── Bounties Endpoint ─────────────────────────────────────────── + + describe('GET /bounties/:id', () => { + it('should return 404 when bounty not found', async () => { + const res = await app.request('/bounties/00000000-0000-0000-0000-000000000000'); + expect(res.status).toBe(404); + }); + + it('should return bounty details when found', async () => { + const foundDb: DbLike = { + execute: async () => ({ + rows: [ + { + id: '11111111-1111-1111-1111-111111111111', + github_issue_id: 123, + repo_owner: 'acme', + repo_name: 'repo', + title: 'Test bounty', + description: 'Desc', + amount_usdc: '100', + tech_tags: ['ts'], + difficulty: 'beginner', + status: 'open', + deadline: null, + creator_id: '22222222-2222-2222-2222-222222222222', + assignee_id: '33333333-3333-3333-3333-333333333333', + created_at: '2026-02-19T00:00:00.000Z', + updated_at: '2026-02-19T00:00:00.000Z', + creator_username: 'creator', + creator_avatar_url: 'https://example.com/c.png', + assignee_username: 'assignee', + assignee_avatar_url: 'https://example.com/a.png', + application_count: 7, + }, + ], + }), + }; + + const app2 = createApp({ db: foundDb }); + const res = await app2.request('/bounties/11111111-1111-1111-1111-111111111111'); + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.id).toBe('11111111-1111-1111-1111-111111111111'); + expect(body.creator.username).toBe('creator'); + expect(body.applicationCount).toBe(7); + expect(body.assignee.username).toBe('assignee'); + }); + }); }); diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 13365cf..f64479c 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -1,13 +1,22 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; +import { sql } from 'drizzle-orm'; + +export type DbLike = { + execute: (query: any) => Promise<{ rows: any[] }>; +}; + +export type CreateAppDeps = { + db: DbLike; +}; /** * Creates and configures the Hono application with all routes and middleware. * Extracted from index.ts to enable testing without triggering server startup * or environment variable validation side effects. */ -export function createApp() { +export function createApp(deps?: Partial) { const app = new Hono(); // Global middleware @@ -57,5 +66,90 @@ export function createApp() { }); }); + // --- Bounties --- + + app.get('/bounties/:id', async (c) => { + const bountyId = c.req.param('id'); + + if (!bountyId || typeof bountyId !== 'string') { + return c.json({ error: 'Invalid bounty id' }, 400); + } + + const db = deps?.db; + if (!db) { + // Keep app.ts test-friendly: the real db must be injected by the server entrypoint. + throw new Error('Database dependency not provided'); + } + + const q = sql` + SELECT + b.id, + b.github_issue_id, + b.repo_owner, + b.repo_name, + b.title, + b.description, + b.amount_usdc, + b.tech_tags, + b.difficulty, + b.status, + b.deadline, + b.creator_id, + b.assignee_id, + b.created_at, + b.updated_at, + creator.username AS creator_username, + creator.avatar_url AS creator_avatar_url, + assignee.username AS assignee_username, + assignee.avatar_url AS assignee_avatar_url, + ( + SELECT COUNT(*)::int + FROM applications a + WHERE a.bounty_id = b.id + ) AS application_count + FROM bounties b + JOIN users creator ON creator.id = b.creator_id + LEFT JOIN users assignee ON assignee.id = b.assignee_id + WHERE b.id = ${bountyId} + LIMIT 1; + `; + + const result = await db.execute(q); + const row = result.rows?.[0]; + + if (!row) { + return c.json({ error: 'Bounty not found' }, 404); + } + + return c.json({ + id: row.id, + githubIssueId: row.github_issue_id, + repoOwner: row.repo_owner, + repoName: row.repo_name, + title: row.title, + description: row.description, + amountUsdc: row.amount_usdc, + techTags: row.tech_tags, + difficulty: row.difficulty, + status: row.status, + deadline: row.deadline, + createdAt: row.created_at, + updatedAt: row.updated_at, + creator: { + id: row.creator_id, + username: row.creator_username, + avatarUrl: row.creator_avatar_url, + }, + applicationCount: row.application_count ?? 0, + assignee: row.assignee_id + ? { + id: row.assignee_id, + username: row.assignee_username, + avatarUrl: row.assignee_avatar_url, + } + : null, + }); + }); + return app; } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 6ec17c1..3fbd97c 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,6 +1,7 @@ import { serve } from '@hono/node-server'; import dotenv from 'dotenv'; import { createApp } from './app'; +import { db } from './db'; // Load environment variables dotenv.config(); @@ -55,7 +56,7 @@ if (missingOptional.length > 0) { ); } -const app = createApp(); +const app = createApp({ db }); const port = Number(process.env.PORT) || 3001; console.log(`Server is running on http://localhost:${port}`); From e9f0e20ccaad6266069e59a31a51b49d381b70f2 Mon Sep 17 00:00:00 2001 From: Genesis Date: Thu, 19 Feb 2026 19:27:07 -0500 Subject: [PATCH 2/6] feat(api): add GET /bounties/:id bounty detail endpoint --- pnpm-lock.yaml | 461 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 461 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c85b9f6..f021c3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,13 +51,22 @@ importers: '@hono/node-server': specifier: ^1.19.9 version: 1.19.9(hono@4.11.9) + '@neondatabase/serverless': + specifier: ^1.0.2 + version: 1.0.2 dotenv: specifier: ^17.3.1 version: 17.3.1 + drizzle-orm: + specifier: ^0.45.1 + version: 0.45.1(@neondatabase/serverless@1.0.2)(@types/pg@8.16.0) hono: specifier: ^4.11.9 version: 4.11.9 devDependencies: + drizzle-kit: + specifier: ^0.31.9 + version: 0.31.9 ts-node-dev: specifier: ^2.0.0 version: 2.0.0(@types/node@22.19.11)(typescript@5.8.3) @@ -194,102 +203,209 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -302,6 +418,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -314,6 +436,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -326,24 +454,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -429,6 +581,10 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@neondatabase/serverless@1.0.2': + resolution: {integrity: sha512-I5sbpSIAHiB+b6UttofhrN/UJXII+4tZPAq1qugzwCwLIL8EZLV7F/JyHUrEIiGgQpEXzpnjlJ+zwcEhheGvCw==} + engines: {node: '>=19.0.0'} + '@reduxjs/toolkit@2.11.2': resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} peerDependencies: @@ -671,6 +827,9 @@ packages: '@types/node@22.19.11': resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} + '@types/pg@8.16.0': + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + '@types/qs@6.14.0': resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} @@ -1019,6 +1178,102 @@ packages: resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} + drizzle-kit@0.31.9: + resolution: {integrity: sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==} + hasBin: true + + drizzle-orm@0.45.1: + resolution: {integrity: sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1054,6 +1309,16 @@ packages: es-toolkit@1.44.0: resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==} + esbuild-register@3.6.0: + resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} + peerDependencies: + esbuild: '>=0.12 <1' + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -1206,6 +1471,9 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1487,6 +1755,17 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-protocol@1.11.0: + resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1502,6 +1781,22 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1603,6 +1898,9 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -2063,81 +2361,159 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@drizzle-team/brocli@0.10.2': {} + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.13.6 + '@esbuild/aix-ppc64@0.25.12': optional: true + '@esbuild/android-arm64@0.18.20': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true + '@esbuild/android-arm@0.18.20': + optional: true + '@esbuild/android-arm@0.25.12': optional: true + '@esbuild/android-x64@0.18.20': + optional: true + '@esbuild/android-x64@0.25.12': optional: true + '@esbuild/darwin-arm64@0.18.20': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true + '@esbuild/darwin-x64@0.18.20': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true + '@esbuild/freebsd-arm64@0.18.20': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true + '@esbuild/freebsd-x64@0.18.20': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true + '@esbuild/linux-arm64@0.18.20': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true + '@esbuild/linux-arm@0.18.20': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true + '@esbuild/linux-ia32@0.18.20': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true + '@esbuild/linux-loong64@0.18.20': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true + '@esbuild/linux-mips64el@0.18.20': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true + '@esbuild/linux-ppc64@0.18.20': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true + '@esbuild/linux-riscv64@0.18.20': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true + '@esbuild/linux-s390x@0.18.20': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true + '@esbuild/linux-x64@0.18.20': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.25.12': optional: true + '@esbuild/netbsd-x64@0.18.20': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.25.12': optional: true + '@esbuild/openbsd-x64@0.18.20': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openharmony-arm64@0.25.12': optional: true + '@esbuild/sunos-x64@0.18.20': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true + '@esbuild/win32-arm64@0.18.20': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true + '@esbuild/win32-ia32@0.18.20': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true + '@esbuild/win32-x64@0.18.20': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true @@ -2226,6 +2602,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@neondatabase/serverless@1.0.2': + dependencies: + '@types/node': 22.19.11 + '@types/pg': 8.16.0 + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': dependencies: '@standard-schema/spec': 1.1.0 @@ -2415,6 +2796,12 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pg@8.16.0': + dependencies: + '@types/node': 22.19.11 + pg-protocol: 1.11.0 + pg-types: 2.2.0 + '@types/qs@6.14.0': {} '@types/range-parser@1.2.7': {} @@ -2788,6 +3175,20 @@ snapshots: dotenv@17.3.1: {} + drizzle-kit@0.31.9: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + esbuild-register: 3.6.0(esbuild@0.25.12) + transitivePeerDependencies: + - supports-color + + drizzle-orm@0.45.1(@neondatabase/serverless@1.0.2)(@types/pg@8.16.0): + optionalDependencies: + '@neondatabase/serverless': 1.0.2 + '@types/pg': 8.16.0 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2816,6 +3217,38 @@ snapshots: es-toolkit@1.44.0: {} + esbuild-register@3.6.0(esbuild@0.25.12): + dependencies: + debug: 4.4.3 + esbuild: 0.25.12 + transitivePeerDependencies: + - supports-color + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -3034,6 +3467,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -3255,6 +3692,18 @@ snapshots: pathe@2.0.3: {} + pg-int8@1.0.1: {} + + pg-protocol@1.11.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -3267,6 +3716,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prelude-ls@1.2.1: {} prettier@3.8.1: {} @@ -3359,6 +3818,8 @@ snapshots: resolve-from@4.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve@1.22.11: dependencies: is-core-module: 2.16.1 From afa8063843746549438741cf33b67ef9922afcfe Mon Sep 17 00:00:00 2001 From: Genesis Date: Thu, 19 Feb 2026 20:27:03 -0500 Subject: [PATCH 3/6] fix(api): validate bounty id format and harden /bounties/:id response --- packages/api/src/__tests__/app.test.ts | 180 ++++++++++++------------- packages/api/src/app.ts | 44 ++---- 2 files changed, 94 insertions(+), 130 deletions(-) diff --git a/packages/api/src/__tests__/app.test.ts b/packages/api/src/__tests__/app.test.ts index 3c66faf..ee6419b 100644 --- a/packages/api/src/__tests__/app.test.ts +++ b/packages/api/src/__tests__/app.test.ts @@ -1,132 +1,120 @@ -import { describe, it, expect, beforeAll } from 'vitest'; -import { createApp, type DbLike } from '../app'; +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { createApp } from '../app'; describe('API App', () => { - let app: ReturnType; - - const mockDb: DbLike = { - execute: async () => ({ rows: [] }), + const mockDb = { + execute: vi.fn(), }; + let app: ReturnType; + beforeAll(() => { app = createApp({ db: mockDb }); }); - // ── Health Endpoint ────────────────────────────────────────────── - describe('GET /health', () => { it('should return 200 with status ok', async () => { const res = await app.request('/health'); - expect(res.status).toBe(200); - const body = await res.json(); expect(body).toEqual({ status: 'ok' }); }); }); - // ── Gemini Endpoint ────────────────────────────────────────────── - - describe('POST /api/gemini', () => { - it('should return 200 with valid prompt', async () => { - const res = await app.request('/api/gemini', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt: 'Hello, AI!' }), - }); - - expect(res.status).toBe(200); - + describe('GET /bounties/:id', () => { + it('should return 400 for invalid bounty ID format', async () => { + const res = await app.request('/bounties/not-a-valid-uuid'); + expect(res.status).toBe(400); const body = await res.json(); - expect(body).toEqual({ - message: 'Request received securely on backend', - status: 'success', - }); + expect(body.error).toBe('Invalid bounty ID format'); }); - it('should return 400 when prompt is missing', async () => { - const res = await app.request('/api/gemini', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }); - - expect(res.status).toBe(400); - - const body = await res.json(); - expect(body.error).toBe('Prompt is required and must be a non-empty string'); + it('should return 404 when bounty not found', async () => { + mockDb.execute.mockResolvedValueOnce({ rows: [] }); + const res = await app.request('/bounties/00000000-0000-0000-0000-000000000000'); + expect(res.status).toBe(404); }); - it('should return 400 when prompt is empty string', async () => { - const res = await app.request('/api/gemini', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt: ' ' }), - }); + it('should return bounty details when found', async () => { + const mockRow = { + id: '11111111-1111-1111-1111-111111111111', + github_issue_id: 123, + repo_owner: 'acme', + repo_name: 'repo', + title: 'Test bounty', + description: 'Desc', + amount_usdc: '100', + tech_tags: ['ts'], + difficulty: 'beginner', + status: 'open', + deadline: null, + creator_id: '22222222-2222-2222-2222-222222222222', + assignee_id: '33333333-3333-3333-3333-333333333333', + created_at: '2026-02-19T00:00:00.000Z', + updated_at: '2026-02-19T00:00:00.000Z', + creator_username: 'creator', + creator_avatar_url: 'https://example.com/c.png', + assignee_username: 'assignee', + assignee_avatar_url: 'https://example.com/a.png', + application_count: 7, + }; - expect(res.status).toBe(400); + mockDb.execute.mockResolvedValueOnce({ rows: [mockRow] }); + + const res = await app.request('/bounties/11111111-1111-1111-1111-111111111111'); + expect(res.status).toBe(200); const body = await res.json(); - expect(body.error).toBe('Prompt is required and must be a non-empty string'); - }); - it('should return 400 when prompt is not a string', async () => { - const res = await app.request('/api/gemini', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt: 123 }), + expect(body).toMatchObject({ + id: '11111111-1111-1111-1111-111111111111', + repoOwner: 'acme', + repoName: 'repo', + title: 'Test bounty', + amountUsdc: '100', + applicationCount: 7, + creator: { + username: 'creator', + }, + assignee: { + username: 'assignee', + }, }); - expect(res.status).toBe(400); + // ensure internal IDs are not exposed + expect(body.creator.id).toBeUndefined(); + expect(body.assignee.id).toBeUndefined(); }); - }); - // ── Bounties Endpoint ─────────────────────────────────────────── - - describe('GET /bounties/:id', () => { - it('should return 404 when bounty not found', async () => { - const res = await app.request('/bounties/00000000-0000-0000-0000-000000000000'); - expect(res.status).toBe(404); - }); - - it('should return bounty details when found', async () => { - const foundDb: DbLike = { - execute: async () => ({ - rows: [ - { - id: '11111111-1111-1111-1111-111111111111', - github_issue_id: 123, - repo_owner: 'acme', - repo_name: 'repo', - title: 'Test bounty', - description: 'Desc', - amount_usdc: '100', - tech_tags: ['ts'], - difficulty: 'beginner', - status: 'open', - deadline: null, - creator_id: '22222222-2222-2222-2222-222222222222', - assignee_id: '33333333-3333-3333-3333-333333333333', - created_at: '2026-02-19T00:00:00.000Z', - updated_at: '2026-02-19T00:00:00.000Z', - creator_username: 'creator', - creator_avatar_url: 'https://example.com/c.png', - assignee_username: 'assignee', - assignee_avatar_url: 'https://example.com/a.png', - application_count: 7, - }, - ], - }), + it('should return bounty details with null assignee if unassigned', async () => { + const mockRow = { + id: '11111111-1111-1111-1111-111111111111', + github_issue_id: 123, + repo_owner: 'acme', + repo_name: 'repo', + title: 'Unassigned Bounty', + description: 'Desc', + amount_usdc: '100', + tech_tags: ['ts'], + difficulty: 'beginner', + status: 'open', + deadline: null, + creator_id: '22222222-2222-2222-2222-222222222222', + assignee_id: null, + created_at: '2026-02-19T00:00:00.000Z', + updated_at: '2026-02-19T00:00:00.000Z', + creator_username: 'creator', + creator_avatar_url: 'https://example.com/c.png', + assignee_username: null, + assignee_avatar_url: null, + application_count: 3, }; - const app2 = createApp({ db: foundDb }); - const res = await app2.request('/bounties/11111111-1111-1111-1111-111111111111'); + mockDb.execute.mockResolvedValueOnce({ rows: [mockRow] }); + + const res = await app.request('/bounties/11111111-1111-1111-1111-111111111111'); expect(res.status).toBe(200); const body = await res.json(); - - expect(body.id).toBe('11111111-1111-1111-1111-111111111111'); - expect(body.creator.username).toBe('creator'); - expect(body.applicationCount).toBe(7); - expect(body.assignee.username).toBe('assignee'); + expect(body.assignee).toBeNull(); }); }); }); diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index f64479c..fe50bb0 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -11,26 +11,16 @@ export type CreateAppDeps = { db: DbLike; }; -/** - * Creates and configures the Hono application with all routes and middleware. - * Extracted from index.ts to enable testing without triggering server startup - * or environment variable validation side effects. - */ export function createApp(deps?: Partial) { const app = new Hono(); - // Global middleware app.use('*', logger()); app.use('*', cors()); - // Rate limiter stub middleware app.use('*', async (_c, next) => { - // TODO(#1): Implement a robust rate limiter (e.g., using `@hono/rate-limiter`). - // For now, checks are skipped await next(); }); - // Error handler app.onError((err, c) => { console.error('App Error:', err); if (process.env.NODE_ENV === 'production') { @@ -39,17 +29,11 @@ export function createApp(deps?: Partial) { return c.json({ error: 'Internal server error', message: err.message }, 500); }); - // API Routes - app.get('/health', (c) => { - return c.json({ status: 'ok' }); - }); + app.get('/health', (c) => c.json({ status: 'ok' })); app.post('/api/gemini', async (c) => { const apiKey = process.env.GEMINI_API_KEY; - - if (!apiKey) { - throw new Error('Gemini API key not configured on server'); - } + if (!apiKey) throw new Error('Gemini API key not configured on server'); const body = await c.req.json(); const { prompt } = body; @@ -59,11 +43,7 @@ export function createApp(deps?: Partial) { } console.log('Received prompt:', prompt); - - return c.json({ - message: 'Request received securely on backend', - status: 'success' - }); + return c.json({ message: 'Request received securely on backend', status: 'success' }); }); // --- Bounties --- @@ -71,15 +51,14 @@ export function createApp(deps?: Partial) { app.get('/bounties/:id', async (c) => { const bountyId = c.req.param('id'); - if (!bountyId || typeof bountyId !== 'string') { - return c.json({ error: 'Invalid bounty id' }, 400); + // Validate UUID to avoid DB errors for malformed inputs + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(bountyId)) { + return c.json({ error: 'Invalid bounty ID format' }, 400); } const db = deps?.db; - if (!db) { - // Keep app.ts test-friendly: the real db must be injected by the server entrypoint. - throw new Error('Database dependency not provided'); - } + if (!db) throw new Error('Database dependency not provided'); const q = sql` SELECT @@ -117,10 +96,9 @@ export function createApp(deps?: Partial) { const result = await db.execute(q); const row = result.rows?.[0]; - if (!row) { - return c.json({ error: 'Bounty not found' }, 404); - } + if (!row) return c.json({ error: 'Bounty not found' }, 404); + // Public response: omit internal user UUIDs return c.json({ id: row.id, githubIssueId: row.github_issue_id, @@ -136,14 +114,12 @@ export function createApp(deps?: Partial) { createdAt: row.created_at, updatedAt: row.updated_at, creator: { - id: row.creator_id, username: row.creator_username, avatarUrl: row.creator_avatar_url, }, applicationCount: row.application_count ?? 0, assignee: row.assignee_id ? { - id: row.assignee_id, username: row.assignee_username, avatarUrl: row.assignee_avatar_url, } From 007c83936201df8592f22a869a562b0600ce1b25 Mon Sep 17 00:00:00 2001 From: Genesis Date: Sun, 22 Feb 2026 21:48:18 -0500 Subject: [PATCH 4/6] feat(api): implement POST /bounties/:id/apply endpoint --- packages/api/src/app.ts | 53 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index fe50bb0..acbc292 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -127,5 +127,58 @@ export function createApp(deps?: Partial) { }); }); + app.post('/bounties/:id/apply', async (c) => { + const bountyId = c.req.param('id'); + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(bountyId)) { + return c.json({ error: 'Invalid bounty ID format' }, 400); + } + + const body = await c.req.json(); + const { coverLetter, estimatedTime, experienceLinks, applicantId } = body; + + if (!coverLetter) { + return c.json({ error: 'coverLetter is required' }, 400); + } + + const db = deps?.db; + if (!db) throw new Error('Database dependency not provided'); + + // 1. Check if bounty exists and is open + const bountyCheck = await db.execute(sql`SELECT status FROM bounties WHERE id = ${bountyId} LIMIT 1`); + if (!bountyCheck.rows?.[0]) return c.json({ error: 'Bounty not found' }, 404); + if (bountyCheck.rows[0].status !== 'open') { + return c.json({ error: 'Bounty is no longer open for applications' }, 400); + } + + // 2. Submit application + try { + const q = sql` + INSERT INTO applications ( + bounty_id, + applicant_id, + cover_letter, + estimated_time, + experience_links, + status + ) VALUES ( + ${bountyId}, + ${applicantId}, + ${coverLetter}, + ${estimatedTime || null}, + ${experienceLinks || []}, + 'pending' + ) RETURNING *; + `; + const result = await db.execute(q); + return c.json(result.rows[0], 201); + } catch (err: any) { + if (err.message?.includes('unique constraint') || err.code === '23505') { + return c.json({ error: 'You have already applied for this bounty' }, 400); + } + throw err; + } + }); + return app; } From a2b7e6eba74f04555b021b55e4949f411080045e Mon Sep 17 00:00:00 2001 From: Genesis Date: Sun, 22 Feb 2026 21:54:14 -0500 Subject: [PATCH 5/6] feat(api): address review feedback for bounty application endpoint --- packages/api/src/__tests__/app.test.ts | 75 ++++++++++++++++++++++++++ packages/api/src/app.ts | 10 +++- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/packages/api/src/__tests__/app.test.ts b/packages/api/src/__tests__/app.test.ts index ee6419b..9c1469b 100644 --- a/packages/api/src/__tests__/app.test.ts +++ b/packages/api/src/__tests__/app.test.ts @@ -117,4 +117,79 @@ describe('API App', () => { expect(body.assignee).toBeNull(); }); }); + + describe('POST /bounties/:id/apply', () => { + const bountyId = '11111111-1111-1111-1111-111111111111'; + const applicantId = '22222222-2222-2222-2222-222222222222'; + const payload = { + coverLetter: 'I want this', + applicantId, + estimatedTime: 5, + experienceLinks: ['https://github.com/test'] + }; + + it('should return 400 for invalid bounty ID format', async () => { + const res = await app.request('/bounties/invalid/apply', { + method: 'POST', + body: JSON.stringify(payload) + }); + expect(res.status).toBe(400); + }); + + it('should return 400 if coverLetter is missing', async () => { + const res = await app.request(`/bounties/${bountyId}/apply`, { + method: 'POST', + body: JSON.stringify({ applicantId }) + }); + expect(res.status).toBe(400); + }); + + it('should return 404 if bounty not found', async () => { + mockDb.execute.mockResolvedValueOnce({ rows: [] }); + const res = await app.request(`/bounties/${bountyId}/apply`, { + method: 'POST', + body: JSON.stringify(payload) + }); + expect(res.status).toBe(404); + }); + + it('should return 400 if bounty is not open', async () => { + mockDb.execute.mockResolvedValueOnce({ rows: [{ status: 'assigned' }] }); + const res = await app.request(`/bounties/${bountyId}/apply`, { + method: 'POST', + body: JSON.stringify(payload) + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain('no longer open'); + }); + + it('should return 201 and application data on success', async () => { + mockDb.execute.mockResolvedValueOnce({ rows: [{ status: 'open' }] }); // check + mockDb.execute.mockResolvedValueOnce({ rows: [{ id: 'app-123', ...payload }] }); // insert + + const res = await app.request(`/bounties/${bountyId}/apply`, { + method: 'POST', + body: JSON.stringify(payload) + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.id).toBe('app-123'); + }); + + it('should return 400 on duplicate application', async () => { + mockDb.execute.mockResolvedValueOnce({ rows: [{ status: 'open' }] }); // check + const err = new Error('unique constraint'); + (err as any).code = '23505'; + mockDb.execute.mockRejectedValueOnce(err); // insert fail + + const res = await app.request(`/bounties/${bountyId}/apply`, { + method: 'POST', + body: JSON.stringify(payload) + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain('already applied'); + }); + }); }); diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index acbc292..99c2568 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -135,12 +135,19 @@ export function createApp(deps?: Partial) { } const body = await c.req.json(); + // SECURITY: applicantId MUST be derived from auth context in production. + // For now, we keep it in body as auth middleware is not yet implemented, + // but we add a TODO to fix this IDOR vulnerability. const { coverLetter, estimatedTime, experienceLinks, applicantId } = body; if (!coverLetter) { return c.json({ error: 'coverLetter is required' }, 400); } + if (!applicantId) { + return c.json({ error: 'applicantId is required' }, 400); + } + const db = deps?.db; if (!db) throw new Error('Database dependency not provided'); @@ -173,7 +180,8 @@ export function createApp(deps?: Partial) { const result = await db.execute(q); return c.json(result.rows[0], 201); } catch (err: any) { - if (err.message?.includes('unique constraint') || err.code === '23505') { + // Use SQLSTATE code for more robust error handling + if (err.code === '23505') { return c.json({ error: 'You have already applied for this bounty' }, 400); } throw err; From 66be389fdc7cbcfff35435f1cc07bde7d681782c Mon Sep 17 00:00:00 2001 From: Genesis Date: Sun, 22 Feb 2026 22:13:36 -0500 Subject: [PATCH 6/6] feat(api): fix race condition in bounty application using atomic INSERT SELECT --- packages/api/src/app.ts | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 99c2568..924872a 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -135,9 +135,6 @@ export function createApp(deps?: Partial) { } const body = await c.req.json(); - // SECURITY: applicantId MUST be derived from auth context in production. - // For now, we keep it in body as auth middleware is not yet implemented, - // but we add a TODO to fix this IDOR vulnerability. const { coverLetter, estimatedTime, experienceLinks, applicantId } = body; if (!coverLetter) { @@ -151,14 +148,7 @@ export function createApp(deps?: Partial) { const db = deps?.db; if (!db) throw new Error('Database dependency not provided'); - // 1. Check if bounty exists and is open - const bountyCheck = await db.execute(sql`SELECT status FROM bounties WHERE id = ${bountyId} LIMIT 1`); - if (!bountyCheck.rows?.[0]) return c.json({ error: 'Bounty not found' }, 404); - if (bountyCheck.rows[0].status !== 'open') { - return c.json({ error: 'Bounty is no longer open for applications' }, 400); - } - - // 2. Submit application + // Submit application atomically to prevent race conditions try { const q = sql` INSERT INTO applications ( @@ -168,19 +158,26 @@ export function createApp(deps?: Partial) { estimated_time, experience_links, status - ) VALUES ( + ) + SELECT ${bountyId}, ${applicantId}, ${coverLetter}, ${estimatedTime || null}, ${experienceLinks || []}, 'pending' - ) RETURNING *; + FROM bounties + WHERE id = ${bountyId} AND status = 'open' + RETURNING *; `; const result = await db.execute(q); + + if (result.rows.length === 0) { + return c.json({ error: 'Bounty not found or is no longer open for applications' }, 400); + } + return c.json(result.rows[0], 201); } catch (err: any) { - // Use SQLSTATE code for more robust error handling if (err.code === '23505') { return c.json({ error: 'You have already applied for this bounty' }, 400); }