From 79cd6f5590d4713cf4f406610f66fab51b7b6c75 Mon Sep 17 00:00:00 2001 From: Lates Date: Sun, 21 Dec 2025 15:23:37 -0500 Subject: [PATCH 1/4] fix: ledger privacy, location obfuscation, and api key usage --- __tests__/api/generate-location.test.ts | 115 ++++++++++++++++++++++++ app/api/books/generate/route.ts | 22 +++-- app/page.tsx | 6 +- lib/location-utils.ts | 46 ++++++++++ lib/supabase-admin.ts | 2 +- 5 files changed, 176 insertions(+), 15 deletions(-) create mode 100644 __tests__/api/generate-location.test.ts create mode 100644 lib/location-utils.ts mode change 100755 => 100644 lib/supabase-admin.ts diff --git a/__tests__/api/generate-location.test.ts b/__tests__/api/generate-location.test.ts new file mode 100644 index 0000000..5b28769 --- /dev/null +++ b/__tests__/api/generate-location.test.ts @@ -0,0 +1,115 @@ +import { POST } from "@/app/api/books/generate/route" +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs" +import { cookies } from "next/headers" +import { getWhimsicalLocation } from "@/lib/location-utils" + +// Mock Supabase +jest.mock("@supabase/auth-helpers-nextjs", () => ({ + createRouteHandlerClient: jest.fn(), +})) + +jest.mock("@supabase/supabase-js", () => ({ + createClient: jest.fn(() => ({ + from: jest.fn(), // We won't strictly test the admin client anymore as we switched to route handler client + })), +})) + +// Mock Next.js headers +jest.mock("next/headers", () => ({ + cookies: jest.fn(), +})) + +// Mock internal libs +jest.mock("@/lib/id_generator", () => ({ + generateBookId: jest.fn().mockResolvedValue("TEST-CODE-LOC"), +})) +jest.mock("@/lib/book-utils", () => ({ + parseBookMetadata: jest.fn(() => ({ + title: "Test Book Location", + author: "Test Author", + cover_url: "http://example.com/cover.jpg", + isbn: "0000000001", + })), +})) + +// Mock location utils specifically to verify usage +jest.mock("@/lib/location-utils", () => ({ + getWhimsicalLocation: jest.fn(), +})) + +describe("Generate API Location Privacy", () => { + const mockCookies = { + getAll: jest.fn(), + get: jest.fn(), + } + const mockSupabase = { + auth: { + getUser: jest.fn(), + }, + from: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + ;(cookies as jest.Mock).mockResolvedValue(mockCookies) + ;(createRouteHandlerClient as jest.Mock).mockReturnValue(mockSupabase) + ;(getWhimsicalLocation as jest.Mock).mockResolvedValue("Whimsical Town, USA") + + // Mock auth + mockSupabase.auth.getUser.mockResolvedValue({ + data: { user: { id: "user-loc-123" } }, + error: null, + }) + + // Mock DB calls + const mockInsertSightings = jest.fn().mockResolvedValue({ error: null }) + const mockInsertBooks = jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + single: jest.fn().mockResolvedValue({ data: { id: "book-loc-123" }, error: null }), + }), + }) + + mockSupabase.from.mockImplementation((table) => { + if (table === "books") return { insert: mockInsertBooks } + if (table === "sightings") return { insert: mockInsertSightings } + return { select: jest.fn() } + }) + + // Attach helper to access mocks + // @ts-ignore + mockSupabase._mockInsertBooks = mockInsertBooks + // @ts-ignore + mockSupabase._mockInsertSightings = mockInsertSightings + }) + + it("should use whimsical location instead of raw coordinates in DB inserts", async () => { + const request = { + json: jest.fn().mockResolvedValue({ + book: { title: "Test Book" }, + location: { lat: 38.9, long: -77.0 }, + anonymousId: "anon-loc-123", + }), + url: "http://localhost/api/books/generate", + } as unknown as Request + + await POST(request) + + // Verify getWhimsicalLocation was called with correct coords + expect(getWhimsicalLocation).toHaveBeenCalledWith(38.9, -77.0) + + // Verify Books Insert + // @ts-ignore + const bookInsertCall = mockSupabase._mockInsertBooks.mock.calls[0][0] + expect(bookInsertCall.location).toBe("Whimsical Town, USA") + // Ensure we still store raw coords for mapping + expect(bookInsertCall.lat).toBe(38.9) + expect(bookInsertCall.lon).toBe(-77.0) + + // Verify Sightings Insert + // @ts-ignore + const sightingInsertCall = mockSupabase._mockInsertSightings.mock.calls[0][0] + expect(sightingInsertCall.location).toBe("Whimsical Town, USA") + expect(sightingInsertCall.lat).toBe(38.9) + expect(sightingInsertCall.lon).toBe(-77.0) + }) +}) diff --git a/app/api/books/generate/route.ts b/app/api/books/generate/route.ts index d28f8c8..9f384f5 100644 --- a/app/api/books/generate/route.ts +++ b/app/api/books/generate/route.ts @@ -7,6 +7,7 @@ import { createClient } from "@supabase/supabase-js" import { parseBookMetadata } from "@/lib/book-utils" import { OpenLibraryDoc, getBookCover } from "@/lib/openLibrary" import { BookMetadata } from "@/lib/types" +import { getWhimsicalLocation } from "@/lib/location-utils" export const dynamic = "force-dynamic" @@ -32,14 +33,8 @@ export async function POST(request: Request) { // 2. Generate Code const code = await generateBookId(lat, long) - // 3. Initialize Admin Client for persistence (bypassing RLS for anonymous inserts) - // Use DB_KEY as the service role key - const adminSupabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.DB_KEY!, { - auth: { - persistSession: false, - autoRefreshToken: false, - }, - }) + // 3. (Admin Client already imported as adminSupabase) + // Removed local initialization that used incorrect key. // 4. Prepare Metadata // The 'book' object might be an OpenLibraryDoc or a GoogleBookData object @@ -54,7 +49,10 @@ export async function POST(request: Request) { } // 5. Persist Book - const { data, error } = await adminSupabase + // 5. Persist Book (Using mapped client to respect RLS) + const whimsicalLocation = await getWhimsicalLocation(lat, long) + + const { data, error } = await supabase .from("books") .insert({ code, @@ -64,7 +62,7 @@ export async function POST(request: Request) { author: author, isbn: isbn, cover_url: cover_link, - location: `${lat},${long}`, + location: whimsicalLocation, }) .select() .single() @@ -75,11 +73,11 @@ export async function POST(request: Request) { } // 4. Create Initial Sighting - const { error: sightingError } = await adminSupabase.from("sightings").insert({ + const { error: sightingError } = await supabase.from("sightings").insert({ book_id: data.id, lat, lon: long, - location: `${lat},${long}`, + location: whimsicalLocation, sighting_type: "REGISTER", // If user is logged in, associate with them. Otherwise anonymous. user_id: userId, diff --git a/app/page.tsx b/app/page.tsx index bb5a334..c9738e2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -65,8 +65,10 @@ export default async function Home() { const sightings = rawSightings.map((sighting) => { if (sighting.user_id && usersMap.has(sighting.user_id)) { const fullEmail = usersMap.get(sighting.user_id) || "" - // Security: Mask the email server-side, only exposing the username part. - const maskedEmail = fullEmail.split("@")[0] + // Security: Obfuscate the email server-side. + // Format: first 3 chars + ... + last 3 chars of username. + const username = fullEmail.split("@")[0] + const maskedEmail = `${username.slice(0, 3)}...${username.slice(-3)}` return { ...sighting, user: { diff --git a/lib/location-utils.ts b/lib/location-utils.ts new file mode 100644 index 0000000..d9ee426 --- /dev/null +++ b/lib/location-utils.ts @@ -0,0 +1,46 @@ +/** + * Fetches a "whimsical" location name (suburb, town, city) from a lat/long pair + * using OpenStreetMap's Nominatim API. + * + * This is used to obfuscate precise coordinates in public feeds. + */ +export async function getWhimsicalLocation(lat: number, lon: number): Promise { + try { + const res = await fetch( + `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}&zoom=10`, + { + headers: { + // Nominatim requires a User-Agent. + "User-Agent": "LFL-BookTracker/1.0", + }, + next: { + // Cache for a long time to avoid rate limits on known coords + revalidate: 86400, + }, + } + ) + + if (!res.ok) { + console.warn("Nominatim API Error:", res.status, res.statusText) + return `${lat.toFixed(2)}, ${lon.toFixed(2)}` + } + + const data = await res.json() + + // Extract just the suburb, neighborhood, or town as requested + // Fallback to "The Wilds" if nothing specific found, or coordinate-ish hint + const name = + data.address?.suburb || + data.address?.town || + data.address?.city || + data.address?.village || + data.address?.county || + "The Wilds" + + return name + } catch (error) { + console.error("Failed to fetch whimsical location:", error) + // Fallback to simplified coordinates + return `${lat.toFixed(2)}, ${lon.toFixed(2)}` + } +} diff --git a/lib/supabase-admin.ts b/lib/supabase-admin.ts old mode 100755 new mode 100644 index 6427d0f..d2c632e --- a/lib/supabase-admin.ts +++ b/lib/supabase-admin.ts @@ -4,7 +4,7 @@ import { createClient } from "@supabase/supabase-js" // Bypasses RLS. Use with caution. export const adminSupabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.DB_KEY!, + process.env.DB_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.DB_KEY!, { auth: { persistSession: false, From 88cdda27a5103dd43070bb8b3695779ad0c1bffe Mon Sep 17 00:00:00 2001 From: Lates Date: Sun, 21 Dec 2025 16:40:53 -0500 Subject: [PATCH 2/4] fix: resolved tests for generate api changes --- __tests__/api/generate-auth.test.ts | 6 ++++++ __tests__/app/api/books/generate/route.test.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/__tests__/api/generate-auth.test.ts b/__tests__/api/generate-auth.test.ts index 01abe29..3458fec 100644 --- a/__tests__/api/generate-auth.test.ts +++ b/__tests__/api/generate-auth.test.ts @@ -41,6 +41,10 @@ jest.mock("@/lib/book-utils", () => ({ })), })) +jest.mock("@/lib/location-utils", () => ({ + getWhimsicalLocation: jest.fn().mockResolvedValue("Mock Town"), +})) + describe("Generate API Auth", () => { const mockCookies = { getAll: jest.fn(), @@ -101,6 +105,7 @@ describe("Generate API Auth", () => { anonymousId: "anon-123", }), url: "http://localhost/api/books/generate", + headers: new Headers(), } as unknown as Request await POST(request) @@ -131,6 +136,7 @@ describe("Generate API Auth", () => { anonymousId: "anon-123", }), url: "http://localhost/api/books/generate", + headers: new Headers(), } as unknown as Request await POST(request) diff --git a/__tests__/app/api/books/generate/route.test.ts b/__tests__/app/api/books/generate/route.test.ts index 23d642b..2de8170 100644 --- a/__tests__/app/api/books/generate/route.test.ts +++ b/__tests__/app/api/books/generate/route.test.ts @@ -28,6 +28,13 @@ jest.mock("@supabase/auth-helpers-nextjs", () => ({ getSession: jest.fn().mockResolvedValue({ data: { session: null } }), getUser: jest.fn().mockResolvedValue({ data: { user: null } }), }, + from: jest.fn().mockReturnValue({ + insert: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + single: jest.fn().mockResolvedValue({ data: { id: "test-id" }, error: null }), + }), + }), + }), }), })) @@ -47,6 +54,7 @@ jest.mock("@/lib/id_generator", () => ({ generateBookId: jest.fn().mockResolvedValue("TEST-CODE"), })) + jest.mock("@/lib/book-utils", () => ({ parseBookMetadata: jest.fn().mockReturnValue({ title: "Test Title", @@ -56,6 +64,10 @@ jest.mock("@/lib/book-utils", () => ({ }), })) +jest.mock("@/lib/location-utils", () => ({ + getWhimsicalLocation: jest.fn().mockResolvedValue("Mock Town"), +})) + describe("POST /api/books/generate", () => { it("should generate a book code successfully", async () => { const request = new Request("http://localhost:3000/api/books/generate", { From 62845a7dab4fe66a506eb5d378c0a05a97512780 Mon Sep 17 00:00:00 2001 From: Lates Date: Sun, 21 Dec 2025 19:05:42 -0500 Subject: [PATCH 3/4] fix: refined whimsical location formatting and fallback --- __tests__/lib/location-utils.test.ts | 92 ++++++++++++++++++++++++++++ lib/location-utils.ts | 34 ++++++---- 2 files changed, 113 insertions(+), 13 deletions(-) create mode 100644 __tests__/lib/location-utils.test.ts diff --git a/__tests__/lib/location-utils.test.ts b/__tests__/lib/location-utils.test.ts new file mode 100644 index 0000000..906a20f --- /dev/null +++ b/__tests__/lib/location-utils.test.ts @@ -0,0 +1,92 @@ + +import { getWhimsicalLocation } from "@/lib/location-utils" + +// Mock global fetch +global.fetch = jest.fn() + +describe("getWhimsicalLocation", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should return 'Near [Neighbourhood] in [City]' when both are present", async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ + address: { + neighbourhood: "Cooktown", + town: "Herndon", + country: "United States", + }, + }), + }) + + const result = await getWhimsicalLocation(38.9, -77.0) + expect(result).toBe("Near Cooktown in Herndon") + // Verify zoom level change + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("zoom=14"), + expect.any(Object) + ) + }) + + it("should return 'Near [Neighbourhood]' when only neighbourhood is present", async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ + address: { + suburb: "Suburbtown", // Using suburb as partial alias for neighbourhood logic + }, + }), + }) + + const result = await getWhimsicalLocation(10, 20) + expect(result).toBe("Near Suburbtown") + }) + + it("should return '[City]' when only city is present", async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ + address: { + city: "Buffalo", + }, + }), + }) + + const result = await getWhimsicalLocation(10, 20) + expect(result).toBe("Buffalo") + }) + + it("should return 'The Wilds' when no relevant address parts are found", async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ + address: { + country: "Nowhere Land", + }, + }), + }) + + const result = await getWhimsicalLocation(0, 0) + expect(result).toBe("The Wilds") + }) + + it("should return 'The Wilds' on fetch failure (api error)", async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }) + + const result = await getWhimsicalLocation(0, 0) + expect(result).toBe("The Wilds") + }) + + it("should return 'The Wilds' on network exception", async () => { + (fetch as jest.Mock).mockRejectedValue(new Error("Network Error")) + + const result = await getWhimsicalLocation(0, 0) + expect(result).toBe("The Wilds") + }) +}) diff --git a/lib/location-utils.ts b/lib/location-utils.ts index d9ee426..90f3930 100644 --- a/lib/location-utils.ts +++ b/lib/location-utils.ts @@ -7,7 +7,7 @@ export async function getWhimsicalLocation(lat: number, lon: number): Promise { try { const res = await fetch( - `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}&zoom=10`, + `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}&zoom=14`, { headers: { // Nominatim requires a User-Agent. @@ -22,25 +22,33 @@ export async function getWhimsicalLocation(lat: number, lon: number): Promise Date: Sun, 21 Dec 2025 19:09:35 -0500 Subject: [PATCH 4/4] fix: typo in user-agent url --- lib/location-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/location-utils.ts b/lib/location-utils.ts index 90f3930..5a9a31d 100644 --- a/lib/location-utils.ts +++ b/lib/location-utils.ts @@ -11,7 +11,7 @@ export async function getWhimsicalLocation(lat: number, lon: number): Promise