From 577de57d8d07ec7240952e9aaee8f0a97f653957 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 21:01:11 +0000 Subject: [PATCH 1/4] fix: keep owner id from token and stop exposing account emails - saved-calcs and contributor endpoints spread the request body before the verified user id, so a body `userId` can no longer override it (Vercel functions and Express). - Community data and CSV export attribute rows by display name only; the username (the account email for Firebase users) fallback is gone. - The public contributors list selects explicit columns instead of c.* plus username, so neither email nor user_id is returned. - Contributors without a display name show as "Anonymous". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PnPh61Aps5neY87hh1pu9T --- api/_lib/neonDb.js | 6 +- api/community-data.js | 3 +- api/contributor.js | 2 +- api/saved-calcs.js | 2 +- app/src/components/DataTransparency.jsx | 2 +- .../__tests__/apiOwnershipAndPrivacy.test.js | 76 +++++++++++++++++++ server/adapters/sqliteDb.js | 4 +- server/server.js | 10 +-- 8 files changed, 89 insertions(+), 16 deletions(-) create mode 100644 app/src/lib/__tests__/apiOwnershipAndPrivacy.test.js diff --git a/api/_lib/neonDb.js b/api/_lib/neonDb.js index 9cefe35..58dd9a6 100644 --- a/api/_lib/neonDb.js +++ b/api/_lib/neonDb.js @@ -260,12 +260,12 @@ export function makeNeonAdapter() { async listContributors() { const result = await query( - `SELECT c.*, u.username, COUNT(ud.id) as contribution_count + `SELECT c.id, c.display_name, c.organization, c.bio, + COUNT(ud.id) as contribution_count FROM contributors c - JOIN users u ON c.user_id = u.id LEFT JOIN user_data ud ON c.user_id = ud.user_id WHERE c.show_on_page = true - GROUP BY c.id, u.username + GROUP BY c.id ORDER BY contribution_count DESC` ); return result.rows; diff --git a/api/community-data.js b/api/community-data.js index 6548994..0ce9b34 100644 --- a/api/community-data.js +++ b/api/community-data.js @@ -10,10 +10,9 @@ function sanitizeCsvValue(value) { const DATA_QUERY = ` SELECT ud.id, ud.species, ud.product, ud.yield, ud.source, - COALESCE(c.display_name, u.username) AS contributor, + NULLIF(TRIM(c.display_name), '') AS contributor, c.organization FROM user_data ud - JOIN users u ON ud.user_id = u.id LEFT JOIN contributors c ON ud.user_id = c.user_id WHERE ud.is_shared = true ORDER BY ud.species ASC, ud.product ASC diff --git a/api/contributor.js b/api/contributor.js index cdaf65e..34111a7 100644 --- a/api/contributor.js +++ b/api/contributor.js @@ -22,7 +22,7 @@ async function handler(req, res) { if (req.method === 'POST') { const { status, body } = await handleSaveContributorProfile( - { userId, ...req.body }, + { ...req.body, userId }, db ); return res.status(status).json(body); diff --git a/api/saved-calcs.js b/api/saved-calcs.js index eb831e5..ca6bd82 100644 --- a/api/saved-calcs.js +++ b/api/saved-calcs.js @@ -44,7 +44,7 @@ async function handler(req, res) { // expected by the transport-agnostic handler. const { client_id: clientId, ...rest } = req.body ?? {}; const { status, body } = await handleSaveCalc( - { userId: req.user.id, clientId, ...rest }, + { ...rest, clientId, userId: req.user.id }, db ); return res.status(status).json(body); diff --git a/app/src/components/DataTransparency.jsx b/app/src/components/DataTransparency.jsx index 6a41fec..35a76ab 100644 --- a/app/src/components/DataTransparency.jsx +++ b/app/src/components/DataTransparency.jsx @@ -185,7 +185,7 @@ const DataTransparency = () => {

- {contributor.display_name} + {contributor.display_name || 'Anonymous'}

{contributor.organization && (

diff --git a/app/src/lib/__tests__/apiOwnershipAndPrivacy.test.js b/app/src/lib/__tests__/apiOwnershipAndPrivacy.test.js new file mode 100644 index 0000000..eea32b9 --- /dev/null +++ b/app/src/lib/__tests__/apiOwnershipAndPrivacy.test.js @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Pass-through auth/CORS so the tests exercise only the endpoint bodies. +vi.mock('../../../../api/_lib/auth.js', () => ({ + requireAuth: (handler) => (req, res) => { + req.user = { id: 7 }; + return handler(req, res); + }, +})); +vi.mock('../../../../api/_lib/cors.js', () => ({ handleCors: (handler) => handler })); + +const query = vi.fn(); +vi.mock('../../../../api/_lib/db.js', () => ({ query: (...args) => query(...args) })); + +const { default: savedCalcs } = await import('../../../../api/saved-calcs.js'); +const { default: contributor } = await import('../../../../api/contributor.js'); +const { default: communityData } = await import('../../../../api/community-data.js'); +const { default: contributors } = await import('../../../../api/contributors.js'); + +function makeRes() { + const res = { statusCode: 200, body: undefined }; + res.status = (code) => { res.statusCode = code; return res; }; + res.json = (body) => { res.body = body; return res; }; + res.send = (body) => { res.body = body; return res; }; + res.setHeader = () => res; + return res; +} + +beforeEach(() => { + query.mockReset(); +}); + +describe('owner id comes from the verified token, never the body', () => { + it('saves a calculation under the signed-in user even if the body names another', async () => { + query.mockResolvedValue({ rows: [{ id: 1 }] }); + const req = { method: 'POST', body: { userId: 999, name: 'x', species: 'Pink Salmon' }, query: {} }; + + await savedCalcs(req, makeRes()); + + const insert = query.mock.calls.find(([sql]) => /INSERT INTO calculations/i.test(sql)); + expect(insert[1]).toContain(7); + expect(insert[1]).not.toContain(999); + }); + + it('saves a contributor profile under the signed-in user even if the body names another', async () => { + query.mockResolvedValue({ rows: [{ id: 1 }] }); + const req = { method: 'POST', body: { userId: 999, display_name: 'Deckhand' }, query: {} }; + + await contributor(req, makeRes()); + + for (const [, params] of query.mock.calls) { + expect(params).not.toContain(999); + } + expect(query.mock.calls.some(([, params]) => params?.includes(7))).toBe(true); + }); +}); + +describe('public endpoints never fall back to the account username/email', () => { + it('community data attributes rows by display name only', async () => { + query.mockResolvedValue({ rows: [] }); + + await communityData({ method: 'GET', query: {} }, makeRes()); + + const [sql] = query.mock.calls[0]; + expect(sql).not.toMatch(/username/i); + }); + + it('contributors list selects no username or user id', async () => { + query.mockResolvedValue({ rows: [] }); + + await contributors({ method: 'GET', query: {} }, makeRes()); + + const [sql] = query.mock.calls[0]; + expect(sql).not.toMatch(/username|c\.\*|user_id\s*,|\bu\./i); + }); +}); diff --git a/server/adapters/sqliteDb.js b/server/adapters/sqliteDb.js index c28b0a8..3665ea0 100644 --- a/server/adapters/sqliteDb.js +++ b/server/adapters/sqliteDb.js @@ -262,9 +262,9 @@ function makeSqliteAdapter(db) { listContributors() { return new Promise((resolve, reject) => { db.all( - `SELECT c.*, u.username, COUNT(ud.id) as contribution_count + `SELECT c.id, c.display_name, c.organization, c.bio, + COUNT(ud.id) as contribution_count FROM contributors c - JOIN users u ON c.user_id = u.id LEFT JOIN user_data ud ON c.user_id = ud.user_id WHERE c.show_on_page = 1 GROUP BY c.id diff --git a/server/server.js b/server/server.js index 3ef6867..602d956 100644 --- a/server/server.js +++ b/server/server.js @@ -289,7 +289,7 @@ async function handleSaveCalcRequest(req, res) { const dbAdapter = makeSqliteAdapter(db); const { client_id: clientId, ...rest } = req.body ?? {}; const { status, body } = await handleSaveCalc( - { userId: req.user.id, clientId, ...rest }, + { ...rest, clientId, userId: req.user.id }, dbAdapter ); return res.status(status).json(body); @@ -571,7 +571,7 @@ app.post('/api/contributor', authenticate, async (req, res) => { const { handleSaveContributorProfile } = await handlersModulePromise; const dbAdapter = makeSqliteAdapter(db); const { status, body } = await handleSaveContributorProfile( - { userId: req.user.id, ...req.body }, + { ...req.body, userId: req.user.id }, dbAdapter ); return res.status(status).json(body); @@ -612,10 +612,9 @@ app.post('/api/user-data/:id/unshare', authenticate, async (req, res) => { app.get('/api/community-data', (req, res) => { const sql = ` SELECT ud.id, ud.species, ud.product, ud.yield, ud.source, - COALESCE(c.display_name, u.username) AS contributor, + NULLIF(TRIM(c.display_name), '') AS contributor, c.organization FROM user_data ud - JOIN users u ON ud.user_id = u.id LEFT JOIN contributors c ON ud.user_id = c.user_id WHERE ud.is_shared = 1 ORDER BY ud.species ASC, ud.product ASC @@ -630,10 +629,9 @@ app.get('/api/community-data', (req, res) => { app.get('/api/export-community-data', (req, res) => { const sql = ` SELECT ud.species, ud.product, ud.yield, ud.source, - COALESCE(c.display_name, u.username) AS contributor, + NULLIF(TRIM(c.display_name), '') AS contributor, c.organization FROM user_data ud - JOIN users u ON ud.user_id = u.id LEFT JOIN contributors c ON ud.user_id = c.user_id WHERE ud.is_shared = 1 ORDER BY ud.species ASC From 663e2c6ea2379f38f53336898b5b813ea12d89d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 21:04:48 +0000 Subject: [PATCH 2/4] fix: show Anonymous for whitespace-only contributor names Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PnPh61Aps5neY87hh1pu9T --- app/src/components/DataTransparency.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/components/DataTransparency.jsx b/app/src/components/DataTransparency.jsx index 35a76ab..f45c1f0 100644 --- a/app/src/components/DataTransparency.jsx +++ b/app/src/components/DataTransparency.jsx @@ -185,7 +185,7 @@ const DataTransparency = () => {

- {contributor.display_name || 'Anonymous'} + {contributor.display_name?.trim() || 'Anonymous'}

{contributor.organization && (

From 6f75b3554261f28f8bee8a7373c6c3c3cc1ed964 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 21:05:22 +0000 Subject: [PATCH 3/4] fix: label unnamed contributors Anonymous in CSV and update API docs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PnPh61Aps5neY87hh1pu9T --- api/community-data.js | 2 +- app/src/lib/__tests__/apiOwnershipAndPrivacy.test.js | 9 +++++++++ docs/API.md | 5 +---- server/server.js | 4 ++-- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/api/community-data.js b/api/community-data.js index 0ce9b34..e35a7a4 100644 --- a/api/community-data.js +++ b/api/community-data.js @@ -10,7 +10,7 @@ function sanitizeCsvValue(value) { const DATA_QUERY = ` SELECT ud.id, ud.species, ud.product, ud.yield, ud.source, - NULLIF(TRIM(c.display_name), '') AS contributor, + COALESCE(NULLIF(TRIM(c.display_name), ''), 'Anonymous') AS contributor, c.organization FROM user_data ud LEFT JOIN contributors c ON ud.user_id = c.user_id diff --git a/app/src/lib/__tests__/apiOwnershipAndPrivacy.test.js b/app/src/lib/__tests__/apiOwnershipAndPrivacy.test.js index eea32b9..26a43f4 100644 --- a/app/src/lib/__tests__/apiOwnershipAndPrivacy.test.js +++ b/app/src/lib/__tests__/apiOwnershipAndPrivacy.test.js @@ -65,6 +65,15 @@ describe('public endpoints never fall back to the account username/email', () => expect(sql).not.toMatch(/username/i); }); + it('community CSV labels rows without a display name as Anonymous', async () => { + query.mockResolvedValue({ rows: [] }); + + await communityData({ method: 'GET', query: { format: 'csv' } }, makeRes()); + + const [sql] = query.mock.calls[0]; + expect(sql).toMatch(/'Anonymous'\) AS contributor/); + }); + it('contributors list selects no username or user id', async () => { query.mockResolvedValue({ rows: [] }); diff --git a/docs/API.md b/docs/API.md index 88f9538..a34b5a3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -445,7 +445,7 @@ Retrieve the 100 most recent calculations. No authentication required. ### Get Contributors -Retrieve opt-in contributor profiles. No authentication required. +Retrieve opt-in contributor profiles. No authentication required. Account usernames, emails and user ids are never returned; `display_name` may be `null`, and clients show it as "Anonymous". **Endpoint:** `GET /api/contributors` @@ -455,12 +455,9 @@ Retrieve opt-in contributor profiles. No authentication required. [ { "id": 1, - "user_id": 42, - "username": "fisherman_joe", "display_name": "Joe Fisher", "organization": "Pacific Catch Co.", "bio": "20 years of commercial fishing.", - "show_on_page": true, "contribution_count": 15 } ] diff --git a/server/server.js b/server/server.js index 602d956..24ba1d2 100644 --- a/server/server.js +++ b/server/server.js @@ -612,7 +612,7 @@ app.post('/api/user-data/:id/unshare', authenticate, async (req, res) => { app.get('/api/community-data', (req, res) => { const sql = ` SELECT ud.id, ud.species, ud.product, ud.yield, ud.source, - NULLIF(TRIM(c.display_name), '') AS contributor, + COALESCE(NULLIF(TRIM(c.display_name), ''), 'Anonymous') AS contributor, c.organization FROM user_data ud LEFT JOIN contributors c ON ud.user_id = c.user_id @@ -629,7 +629,7 @@ app.get('/api/community-data', (req, res) => { app.get('/api/export-community-data', (req, res) => { const sql = ` SELECT ud.species, ud.product, ud.yield, ud.source, - NULLIF(TRIM(c.display_name), '') AS contributor, + COALESCE(NULLIF(TRIM(c.display_name), ''), 'Anonymous') AS contributor, c.organization FROM user_data ud LEFT JOIN contributors c ON ud.user_id = c.user_id From 8968020f0b2e88db44bbabd7f91b4286ea695fa7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 04:36:01 +0000 Subject: [PATCH 4/4] test: assert the owner id at its exact query position Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PnPh61Aps5neY87hh1pu9T --- app/src/lib/__tests__/apiOwnerIdFromToken.test.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/lib/__tests__/apiOwnerIdFromToken.test.js b/app/src/lib/__tests__/apiOwnerIdFromToken.test.js index 7547237..4389bef 100644 --- a/app/src/lib/__tests__/apiOwnerIdFromToken.test.js +++ b/app/src/lib/__tests__/apiOwnerIdFromToken.test.js @@ -36,7 +36,7 @@ describe('owner id comes from the verified token, never the body', () => { await savedCalcs(req, makeRes()); const insert = query.mock.calls.find(([sql]) => /INSERT INTO calculations/i.test(sql)); - expect(insert[1]).toContain(7); + expect(insert[1][0]).toBe(7); // user_id expect(insert[1]).not.toContain(999); }); @@ -49,6 +49,8 @@ describe('owner id comes from the verified token, never the body', () => { for (const [, params] of query.mock.calls) { expect(params).not.toContain(999); } - expect(query.mock.calls.some(([, params]) => params?.includes(7))).toBe(true); + const update = query.mock.calls.find(([sql]) => /UPDATE contributors/i.test(sql)); + expect(update).toBeDefined(); + expect(update[1][4]).toBe(7); // WHERE user_id = $5 }); });