From e37aae3e854c829adc5f7b2b5da34ea0123bfe1d Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Tue, 21 Apr 2026 07:32:08 -0700 Subject: [PATCH 1/2] feat(gov): GET /gov/receipts/recent for activity card (PR 6c) New authenticated endpoint backing the "Your activity" card on the governance page. Returns the user's most recent vote receipts (limit clamped to [1, 50]) via voteReceipts.listRecent. Tests use an injected monotonic clock on the repo so submitted-at ordering is deterministic when rows land in the same millisecond. Made-with: Cursor --- routes/gov.js | 49 ++++++++++++ tests/gov.routes.test.js | 160 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) diff --git a/routes/gov.js b/routes/gov.js index 3f1469a..8eb9e86 100644 --- a/routes/gov.js +++ b/routes/gov.js @@ -390,6 +390,55 @@ function createGovRouter({ } ); + // ------------------------------------------------------------------- + // GET /gov/receipts/recent?limit=<1..50> + // + // Returns the user's most recent receipts across ALL proposals, + // ordered by submitted_at DESC. Drives the "Your activity" card on + // the authenticated Governance page. + // + // PURE READ — no RPC, no reconciliation. The rows reflect whatever + // the reconciler last wrote; the UI is expected to pair each entry + // with its proposal from the governance feed to render titles. + // + // The `limit` query param is clamped to [1, 50]. The backing helper + // caps at 100 as a hard ceiling, but we expose a tighter limit at + // the route layer because the UI's card is intentionally small and + // larger limits would only inflate payload size without use. + // + // Response shape: + // { receipts: [{ id, proposalHash, collateralHash, collateralIndex, + // voteOutcome, voteSignal, voteTime, status, + // lastError, submittedAt, verifiedAt }, ...] } + // ------------------------------------------------------------------- + router.get( + '/receipts/recent', + sessionMw.requireAuth, + (req, res) => { + if (!receipts) { + return res.json({ receipts: [] }); + } + const rawLimit = req.query && req.query.limit; + let limit = 10; + if (typeof rawLimit === 'string' && rawLimit.length > 0) { + const parsed = Number.parseInt(rawLimit, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + return res.status(400).json({ error: 'invalid_limit' }); + } + limit = Math.min(parsed, 50); + } + const userId = req.user && req.user.id; + try { + const rows = receipts.listRecent(userId, limit); + return res.json({ receipts: rows }); + } catch (err) { + // eslint-disable-next-line no-console + console.error('[GET /gov/receipts/recent] listRecent failed', err); + return res.status(500).json({ error: 'internal' }); + } + } + ); + return router; } diff --git a/tests/gov.routes.test.js b/tests/gov.routes.test.js index aa6f947..b6ebbc8 100644 --- a/tests/gov.routes.test.js +++ b/tests/gov.routes.test.js @@ -1262,3 +1262,163 @@ describe('GET /gov/receipts/summary', () => { } }); }); + +// --------------------------------------------------------------------------- +// GET /gov/receipts/recent +// +// "Your activity" backing route — the N most-recent receipts across all +// proposals for the calling user, ordered by submitted_at DESC. Pure +// SELECT; no RPC. +// --------------------------------------------------------------------------- + +describe('GET /gov/receipts/recent', () => { + async function userIdFor(ctx, email) { + const row = ctx.users.findByEmail(email); + return row && row.id; + } + + test('401 when unauthenticated', async () => { + const { ctx } = buildApp(); + try { + const res = await request(ctx.app).get('/gov/receipts/recent'); + expect(res.status).toBe(401); + } finally { + ctx.db.close(); + } + }); + + test('returns empty list when the user has no receipts', async () => { + const { ctx } = buildApp(); + try { + const { agent } = await loggedInAgent(ctx); + const res = await agent.get('/gov/receipts/recent'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ receipts: [] }); + } finally { + ctx.db.close(); + } + }); + + test('defaults to 10 and returns rows in submitted_at DESC', async () => { + // Use an injected monotonic clock so every upsert stamps a + // distinct submitted_at. Without this, two upserts inside the + // same millisecond would tie and the ORDER BY submitted_at + // DESC ordering would depend on insertion-order (stable on + // better-sqlite3 but brittle to assert against). + let tick = 1_700_000_000_000; + const { ctx } = buildApp(); + // Rebuild the receipts repo with a tick clock so we can predict + // submitted_at for each upsert. + const { createVoteReceiptsRepo } = require('../lib/voteReceipts'); + ctx.voteReceipts = createVoteReceiptsRepo(ctx.db, { + now: () => { + tick += 1; + return tick; + }, + }); + try { + const { agent } = await loggedInAgent(ctx); + const uid = await userIdFor(ctx, 'user@example.com'); + for (let i = 0; i < 12; i += 1) { + ctx.voteReceipts.upsert({ + userId: uid, + collateralHash: H2, + collateralIndex: i, + proposalHash: H1, + voteOutcome: 'yes', + voteSignal: 'funding', + voteTime: 1_700_000_000 + i, + status: 'confirmed', + }); + } + const res = await agent.get('/gov/receipts/recent'); + expect(res.status).toBe(200); + // NOTE: the route's repo in appFactory is the one that + // handles the request, not the local `ctx.voteReceipts` we + // swapped above — the listRecent it exposes is bound to the + // same db. Upserts above wrote to that db; the route will + // read them back. + expect(res.body.receipts).toHaveLength(10); + // Most recent first: submittedAt strictly non-increasing. + const submitted = res.body.receipts.map((r) => r.submittedAt); + for (let i = 1; i < submitted.length; i += 1) { + expect(submitted[i]).toBeLessThanOrEqual(submitted[i - 1]); + } + } finally { + ctx.db.close(); + } + }); + + test('respects a custom limit and clamps to 50', async () => { + const { ctx } = buildApp(); + try { + const { agent } = await loggedInAgent(ctx); + const uid = await userIdFor(ctx, 'user@example.com'); + for (let i = 0; i < 4; i += 1) { + ctx.voteReceipts.upsert({ + userId: uid, + collateralHash: H2, + collateralIndex: i, + proposalHash: H1, + voteOutcome: 'yes', + voteSignal: 'funding', + voteTime: 1_700_000_000 + i, + status: 'confirmed', + }); + } + const res1 = await agent.get('/gov/receipts/recent?limit=2'); + expect(res1.body.receipts).toHaveLength(2); + + // Values over 50 silently clamp so a client can ask for + // "everything" without coordinating with the server ceiling. + const res2 = await agent.get('/gov/receipts/recent?limit=500'); + expect(res2.status).toBe(200); + expect(res2.body.receipts.length).toBeLessThanOrEqual(50); + } finally { + ctx.db.close(); + } + }); + + test('rejects invalid limit values with 400', async () => { + const { ctx } = buildApp(); + try { + const { agent } = await loggedInAgent(ctx); + const res = await agent.get('/gov/receipts/recent?limit=abc'); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'invalid_limit' }); + + const res2 = await agent.get('/gov/receipts/recent?limit=0'); + expect(res2.status).toBe(400); + + const res3 = await agent.get('/gov/receipts/recent?limit=-5'); + expect(res3.status).toBe(400); + } finally { + ctx.db.close(); + } + }); + + test('does not leak receipts across users', async () => { + const { ctx } = buildApp(); + try { + const { agent: aAgent } = await loggedInAgent(ctx, 'a@example.com'); + const { agent: bAgent } = await loggedInAgent(ctx, 'b@example.com'); + const aId = await userIdFor(ctx, 'a@example.com'); + ctx.voteReceipts.upsert({ + userId: aId, + collateralHash: H2, + collateralIndex: 0, + proposalHash: H1, + voteOutcome: 'yes', + voteSignal: 'funding', + voteTime: 1_700_000_000, + status: 'confirmed', + }); + const aRes = await aAgent.get('/gov/receipts/recent'); + const bRes = await bAgent.get('/gov/receipts/recent'); + expect(aRes.body.receipts).toHaveLength(1); + expect(bRes.body.receipts).toHaveLength(0); + } finally { + ctx.db.close(); + } + }); +}); From 6dba911bdb93512a24407ea17f14e3a1322641c1 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Tue, 21 Apr 2026 08:15:04 -0700 Subject: [PATCH 2/2] fix(gov): strict integer parsing for /gov/receipts/recent limit (Codex round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Number.parseInt silently accepts partially-numeric strings (limit=2abc → 2, limit=1.5 → 1, limit=1e3 → 1). The route contract documents an integer in [1, 50] and clients depend on that being enforced; malformed queries now 400 invalid_limit instead of returning an arbitrary row count. Made-with: Cursor --- routes/gov.js | 11 +++++++++-- tests/gov.routes.test.js | 11 +++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/routes/gov.js b/routes/gov.js index 8eb9e86..2ee88ae 100644 --- a/routes/gov.js +++ b/routes/gov.js @@ -421,8 +421,15 @@ function createGovRouter({ const rawLimit = req.query && req.query.limit; let limit = 10; if (typeof rawLimit === 'string' && rawLimit.length > 0) { - const parsed = Number.parseInt(rawLimit, 10); - if (!Number.isInteger(parsed) || parsed <= 0) { + // Strict digits-only validation: Number.parseInt would + // happily accept "2abc" → 2, "1.5" → 1, "1e3" → 1, and + // silently mask client bugs. The route contract promises + // an integer in [1, 50] and that's what we enforce here. + if (!/^\d+$/.test(rawLimit)) { + return res.status(400).json({ error: 'invalid_limit' }); + } + const parsed = Number(rawLimit); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { return res.status(400).json({ error: 'invalid_limit' }); } limit = Math.min(parsed, 50); diff --git a/tests/gov.routes.test.js b/tests/gov.routes.test.js index b6ebbc8..a753ac9 100644 --- a/tests/gov.routes.test.js +++ b/tests/gov.routes.test.js @@ -1392,6 +1392,17 @@ describe('GET /gov/receipts/recent', () => { const res3 = await agent.get('/gov/receipts/recent?limit=-5'); expect(res3.status).toBe(400); + + // Partially-numeric and scientific-notation strings also + // 400 rather than silently coercing to an integer value. + // Previously Number.parseInt('2abc') → 2 and Number + // .parseInt('1.5') → 1, masking client bugs. + const res4 = await agent.get('/gov/receipts/recent?limit=2abc'); + expect(res4.status).toBe(400); + const res5 = await agent.get('/gov/receipts/recent?limit=1.5'); + expect(res5.status).toBe(400); + const res6 = await agent.get('/gov/receipts/recent?limit=1e3'); + expect(res6.status).toBe(400); } finally { ctx.db.close(); }