diff --git a/apps/web/__tests__/api/cli-dashboard.test.ts b/apps/web/__tests__/api/cli-dashboard.test.ts index 51eca653..00e8aefa 100644 --- a/apps/web/__tests__/api/cli-dashboard.test.ts +++ b/apps/web/__tests__/api/cli-dashboard.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; const mockServiceClient: Record = { + auth: { + admin: { + getUserById: vi.fn().mockResolvedValue({ + data: { user: { id: "user-123", banned_until: null } }, + error: null, + }), + }, + }, from: vi.fn(), rpc: vi.fn(), }; @@ -45,6 +53,38 @@ describe("GET /api/cli/dashboard", () => { vi.useRealTimers(); }); + it("rejects a signed token after the user is banned", async () => { + mockServiceClient.auth.admin.getUserById.mockResolvedValueOnce({ + data: { user: { id: "user-123", banned_until: "2999-01-01T00:00:00Z" } }, + error: null, + }); + + const response = await GET( + new Request("http://localhost/api/cli/dashboard", { + headers: { authorization: "Bearer token" }, + }), + ); + + expect(response.status).toBe(401); + expect(mockServiceClient.from).not.toHaveBeenCalled(); + }); + + it("returns 503 when the identity provider cannot verify the user", async () => { + mockServiceClient.auth.admin.getUserById.mockResolvedValueOnce({ + data: { user: null }, + error: { message: "Request timed out", code: "request_timeout" }, + }); + + const response = await GET( + new Request("http://localhost/api/cli/dashboard", { + headers: { authorization: "Bearer token" }, + }), + ); + + expect(response.status).toBe(503); + expect(mockServiceClient.from).not.toHaveBeenCalled(); + }); + it("aggregates model breakdown from the same last-7-days window as the scorecard", async () => { const profile = chain({ single: vi.fn().mockResolvedValue({ diff --git a/apps/web/__tests__/api/comment-email-notifications.test.ts b/apps/web/__tests__/api/comment-email-notifications.test.ts index 69fcd83c..dbc372a8 100644 --- a/apps/web/__tests__/api/comment-email-notifications.test.ts +++ b/apps/web/__tests__/api/comment-email-notifications.test.ts @@ -100,6 +100,18 @@ function makeSessionClient(commenterId: string, postOwnerId: string) { } function makeServiceClient({ emailNotifications }: { emailNotifications: boolean }) { + const commentsChain = { + insert: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ + data: { + id: "comment-1", + content: "Nice post", + user: { id: "commenter-1", username: "alexesprit" }, + }, + error: null, + }), + }; const usersChain = { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), @@ -122,6 +134,10 @@ function makeServiceClient({ emailNotifications }: { emailNotifications: boolean : { data: null, error: null }, )), from: vi.fn().mockImplementation((table: string) => { + if (table === "comments") return commentsChain; + if (table === "notifications") { + return { insert: vi.fn().mockResolvedValue({ error: null }) }; + } if (table === "users") return usersChain; if (table === "posts") return postsChain; return { diff --git a/apps/web/__tests__/api/messages.test.ts b/apps/web/__tests__/api/messages.test.ts index 4e1e170d..69536c9f 100644 --- a/apps/web/__tests__/api/messages.test.ts +++ b/apps/web/__tests__/api/messages.test.ts @@ -167,7 +167,6 @@ describe("GET /api/messages", () => { }), }; } - throw new Error(`Unexpected table ${table}`); }), }; @@ -276,6 +275,25 @@ describe("POST /api/messages", () => { }), }; } + if (table === "direct_messages") { + return { + insert: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: { + id: "m-1", + sender_id: "user-1", + recipient_id: "user-2", + content: "Hey Alice", + read_at: null, + created_at: "2026-03-06T12:00:00.000Z", + }, + error: null, + }), + }), + }), + }; + } throw new Error(`Unexpected table ${table}`); }), diff --git a/apps/web/__tests__/api/posts.test.ts b/apps/web/__tests__/api/posts.test.ts index f4ba8ceb..66e2a9fc 100644 --- a/apps/web/__tests__/api/posts.test.ts +++ b/apps/web/__tests__/api/posts.test.ts @@ -3,11 +3,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn(), })); -const mockServiceFrom = vi.fn().mockReturnValue({ - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - in: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: { username: "author" }, error: null }), +let currentAuthClient: Record | null = null; +const mockServiceFrom = vi.fn((table: string) => { + if (table === "posts" && currentAuthClient) { + return currentAuthClient.from(table); + } + return { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + in: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: { username: "author" }, error: null }), + }; }); const mockServiceClient = { from: mockServiceFrom, @@ -69,6 +75,7 @@ function mockSupabase(opts: { }; (createClient as any).mockResolvedValue(client); + currentAuthClient = client; return client; } @@ -90,6 +97,7 @@ function makeRequest( beforeEach(() => { vi.clearAllMocks(); + currentAuthClient = null; // Reset service client to default mock between tests mockServiceClient.from = mockServiceFrom; vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", "https://test.supabase.co"); @@ -534,12 +542,14 @@ describe("DELETE /api/posts/[id]", () => { }), }; (createClient as any).mockResolvedValue(client); + currentAuthClient = client; const res = await DELETE(makeRequest("DELETE"), makeContext("post-1")); const json = await res.json(); expect(res.status).toBe(200); expect(json.success).toBe(true); + expect(mockServiceFrom).toHaveBeenCalledWith("posts"); }); it("rejects unauthenticated DELETE", async () => { @@ -586,6 +596,7 @@ describe("DELETE /api/posts/[id]", () => { }), }; (createClient as any).mockResolvedValue(client); + currentAuthClient = client; const res = await DELETE(makeRequest("DELETE"), makeContext("post-1")); const json = await res.json(); diff --git a/apps/web/__tests__/api/profile.test.ts b/apps/web/__tests__/api/profile.test.ts index c4a3f6f5..9435a958 100644 --- a/apps/web/__tests__/api/profile.test.ts +++ b/apps/web/__tests__/api/profile.test.ts @@ -20,6 +20,10 @@ vi.mock("@/lib/analytics/server", () => ({ captureServerActivationEvent: vi.fn().mockResolvedValue(true), })); +vi.mock("@/lib/rate-limit", () => ({ + rateLimit: vi.fn().mockResolvedValue(null), +})); + vi.mock("@/lib/constants/regions", () => ({ COUNTRY_TO_REGION: { US: "north_america", @@ -34,6 +38,7 @@ import { captureServerActivationEvent } from "@/lib/analytics/server"; import { sendWelcomeEmail } from "@/lib/email/send-welcome-email"; import { createClient } from "@/lib/supabase/server"; import { getServiceClient } from "@/lib/supabase/service"; +import { rateLimit } from "@/lib/rate-limit"; import { NextRequest } from "next/server"; function makeContext(username: string) { @@ -50,6 +55,7 @@ function makeRequest(method: string, url: string, body?: any) { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(rateLimit).mockResolvedValue(null); }); describe("GET /api/users/[username]", () => { @@ -363,6 +369,7 @@ describe("PATCH /api/users/me", () => { expect(res.status).toBe(200); expect(json.username).toBe("new_name"); + expect(rateLimit).toHaveBeenCalledWith("profile-update", "u-1", { limit: 20 }); }); it("does not complete onboarding before first sync is present", async () => { @@ -548,6 +555,82 @@ describe("PATCH /api/users/me", () => { expect(json.error).toContain("160 characters"); }); + it("rejects oversized profile identity fields", async () => { + const client: Record = { + auth: { + getUser: vi.fn().mockResolvedValue({ + data: { user: { id: "u-1" } }, + error: null, + }), + }, + }; + (createClient as any).mockResolvedValue(client); + + const res = await PATCH( + makeRequest("PATCH", "/api/users/me", { display_name: "x".repeat(101) }) + ); + + expect(res.status).toBe(400); + }); + + it("rejects avatar URLs outside approved providers", async () => { + const client: Record = { + auth: { + getUser: vi.fn().mockResolvedValue({ + data: { user: { id: "u-1" } }, + error: null, + }), + }, + }; + (createClient as any).mockResolvedValue(client); + + const res = await PATCH( + makeRequest("PATCH", "/api/users/me", { + avatar_url: "https://attacker.example/tracking.svg", + }) + ); + + expect(res.status).toBe(400); + }); + + it("rejects invalid booleans, countries, and timezones", async () => { + const client: Record = { + auth: { + getUser: vi.fn().mockResolvedValue({ + data: { user: { id: "u-1" } }, + error: null, + }), + }, + }; + (createClient as any).mockResolvedValue(client); + + for (const body of [ + { is_public: "true" }, + { country: "XX" }, + { timezone: "Not/A_Timezone" }, + ]) { + const res = await PATCH(makeRequest("PATCH", "/api/users/me", body)); + expect(res.status).toBe(400); + } + }); + + it("normalizes an empty timezone without rejecting the profile update", async () => { + const { updateMock } = mockAuthenticatedProfileUpdate(); + + const res = await PATCH( + makeRequest("PATCH", "/api/users/me", { + display_name: "Alice", + timezone: "", + }) + ); + + expect(res.status).toBe(200); + expect(updateMock).toHaveBeenCalledWith({ + display_name: "Alice", + timezone: "UTC", + }); + }); + it("validates how you heard about Straude length (max 500)", async () => { const client: Record = { auth: { diff --git a/apps/web/__tests__/api/prompts.test.ts b/apps/web/__tests__/api/prompts.test.ts index 6ad568ca..281c699a 100644 --- a/apps/web/__tests__/api/prompts.test.ts +++ b/apps/web/__tests__/api/prompts.test.ts @@ -20,6 +20,13 @@ import { createClient } from "@/lib/supabase/server"; import { getServiceClient } from "@/lib/supabase/service"; import { isAdmin } from "@/lib/admin"; +function allowedRateLimitRpc() { + return vi.fn().mockResolvedValue({ + data: [{ allowed: true, retry_after_seconds: 0 }], + error: null, + }); +} + function makeRequest(method: string, url: string, body?: Record) { return new NextRequest(new URL(url, "http://localhost"), { method, @@ -71,6 +78,10 @@ describe("POST /api/prompts", () => { }), }; (createClient as any).mockResolvedValue(supabase); + (getServiceClient as any).mockReturnValue({ + rpc: allowedRateLimitRpc(), + from: vi.fn().mockReturnValue(insertChain), + }); const res = await postPrompt( makeRequest("POST", "/api/prompts", { @@ -117,6 +128,10 @@ describe("POST /api/prompts", () => { return call === 1 ? countChain : insertChain; }), }); + (getServiceClient as any).mockReturnValue({ + rpc: allowedRateLimitRpc(), + from: vi.fn().mockReturnValue(insertChain), + }); const res = await postPrompt( makeRequest("POST", "/api/prompts", { @@ -144,6 +159,9 @@ describe("POST /api/prompts", () => { from: vi.fn().mockReturnValue(countChain), }; (createClient as any).mockResolvedValue(supabase); + (getServiceClient as any).mockReturnValue({ + rpc: allowedRateLimitRpc(), + }); const res = await postPrompt( makeRequest("POST", "/api/prompts", { diff --git a/apps/web/__tests__/api/search.test.ts b/apps/web/__tests__/api/search.test.ts index 2e24c3a3..4b18ca62 100644 --- a/apps/web/__tests__/api/search.test.ts +++ b/apps/web/__tests__/api/search.test.ts @@ -47,7 +47,7 @@ describe("GET /api/search", () => { mockClients(); const res = await GET(makeRequest({ q: "a" })); expect(res.status).toBe(400); - expect((await res.json()).error).toContain("at least 2 characters"); + expect((await res.json()).error).toContain("between 2 and 64 characters"); }); it("returns 400 for empty query", async () => { @@ -56,6 +56,50 @@ describe("GET /api/search", () => { expect(res.status).toBe(400); }); + it("rejects wildcard-only queries instead of turning them into a full scan", async () => { + const { supabaseChain } = mockClients(); + + const res = await GET(makeRequest({ q: "%%" })); + + expect(res.status).toBe(400); + expect(supabaseChain.or).not.toHaveBeenCalled(); + }); + + it("rejects asterisks instead of passing PostgREST wildcard aliases", async () => { + const { supabaseChain } = mockClients(); + + const res = await GET(makeRequest({ q: "ab*" })); + + expect(res.status).toBe(400); + expect(supabaseChain.or).not.toHaveBeenCalled(); + }); + + it("preserves underscores in valid usernames as literal search characters", async () => { + const { supabaseChain } = mockClients({ + users: [{ id: "u-1", username: "alice_dev" }], + }); + + const res = await GET(makeRequest({ q: "alice_dev" })); + + expect(res.status).toBe(200); + expect(supabaseChain.or).toHaveBeenCalledWith( + 'username.ilike."%alice\\\\_dev%",display_name.ilike."%alice\\\\_dev%",github_username.ilike."%alice\\\\_dev%"', + ); + }); + + it("preserves punctuation in display-name searches", async () => { + const { supabaseChain } = mockClients({ + users: [{ id: "u-1", display_name: "O'Brien" }], + }); + + const res = await GET(makeRequest({ q: "O'Brien" })); + + expect(res.status).toBe(200); + expect(supabaseChain.or).toHaveBeenCalledWith( + 'username.ilike."%O\'Brien%",display_name.ilike."%O\'Brien%",github_username.ilike."%O\'Brien%"', + ); + }); + it("searches by username and github_username via OR filter", async () => { const users = [{ id: "u-1", username: "alice", display_name: "Alice" }]; const { supabaseChain } = mockClients({ users }); @@ -67,7 +111,7 @@ describe("GET /api/search", () => { expect(json.users).toHaveLength(1); expect(json.users[0].username).toBe("alice"); expect(supabaseChain.or).toHaveBeenCalledWith( - "username.ilike.%alice%,display_name.ilike.%alice%,github_username.ilike.%alice%" + 'username.ilike."%alice%",display_name.ilike."%alice%",github_username.ilike."%alice%"' ); }); @@ -77,7 +121,7 @@ describe("GET /api/search", () => { await GET(makeRequest({ q: "bobgithub" })); expect(supabaseChain.or).toHaveBeenCalledWith( - "username.ilike.%bobgithub%,display_name.ilike.%bobgithub%,github_username.ilike.%bobgithub%" + 'username.ilike."%bobgithub%",display_name.ilike."%bobgithub%",github_username.ilike."%bobgithub%"' ); }); @@ -91,7 +135,7 @@ describe("GET /api/search", () => { expect(json.users).toEqual([]); }); - it("still returns username matches that happen to contain @ in the query", async () => { + it("preserves at signs in the query without searching private email fields", async () => { const users = [{ id: "u-1", username: "user_at_sign" }]; const { supabaseChain } = mockClients({ users }); @@ -100,7 +144,7 @@ describe("GET /api/search", () => { expect(res.status).toBe(200); expect(json.users).toHaveLength(1); - expect(supabaseChain.or.mock.calls[0]?.[0]).toContain("usersomething"); + expect(supabaseChain.or.mock.calls[0]?.[0]).toContain("user@something"); }); it("respects limit parameter", async () => { diff --git a/apps/web/__tests__/api/social.test.ts b/apps/web/__tests__/api/social.test.ts index ec57038b..ccf97f61 100644 --- a/apps/web/__tests__/api/social.test.ts +++ b/apps/web/__tests__/api/social.test.ts @@ -6,6 +6,7 @@ vi.mock("@/lib/supabase/server", () => ({ const mockServiceClient = { rpc: vi.fn(), + from: vi.fn(), }; vi.mock("@/lib/supabase/service", () => ({ @@ -51,6 +52,11 @@ function makeRequest( }); } +function useClient(client: Record) { + (createClient as any).mockResolvedValue(client); + mockServiceClient.from.mockImplementation(client.from); +} + beforeEach(() => { vi.clearAllMocks(); mockServiceClient.rpc.mockResolvedValue({ @@ -96,7 +102,7 @@ describe("POST /api/follow/[username]", () => { return {}; }), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await followPOST( makeRequest("POST", "/api/follow/alice"), @@ -127,7 +133,7 @@ describe("POST /api/follow/[username]", () => { }), })), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await followPOST( makeRequest("POST", "/api/follow/myself"), @@ -158,7 +164,7 @@ describe("POST /api/follow/[username]", () => { }), }), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await followPOST( makeRequest("POST", "/api/follow/nobody"), @@ -205,7 +211,7 @@ describe("DELETE /api/follow/[username]", () => { return {}; }), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await followDELETE( makeRequest("DELETE", "/api/follow/alice"), @@ -266,7 +272,7 @@ describe("POST /api/posts/[id]/kudos", () => { return {}; }), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await kudosPOST( makeRequest("POST", "/api/posts/post-1/kudos"), @@ -303,7 +309,7 @@ describe("POST /api/posts/[id]/kudos", () => { throw new Error(`Unexpected table ${table}`); }), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await kudosPOST( makeRequest("POST", "/api/posts/private-post/kudos"), @@ -341,7 +347,7 @@ describe("DELETE /api/posts/[id]/kudos", () => { return {}; }), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await kudosDELETE( makeRequest("DELETE", "/api/posts/post-1/kudos"), @@ -385,7 +391,7 @@ describe("GET /api/posts/[id]/kudos", () => { }), }), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await kudosGET( makeRequest("GET", "/api/posts/post-1/kudos"), @@ -455,7 +461,7 @@ describe("POST /api/posts/[id]/comments", () => { return {}; }), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await commentPOST( makeRequest("POST", "/api/posts/post-1/comments", { @@ -493,7 +499,7 @@ describe("POST /api/posts/[id]/comments", () => { throw new Error(`Unexpected table ${table}`); }), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await commentPOST( makeRequest("POST", "/api/posts/private-post/comments", { @@ -517,7 +523,7 @@ describe("POST /api/posts/[id]/comments", () => { }, from: vi.fn(), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const longContent = "x".repeat(501); const res = await commentPOST( @@ -542,7 +548,7 @@ describe("POST /api/posts/[id]/comments", () => { }, from: vi.fn(), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await commentPOST( makeRequest("POST", "/api/posts/post-1/comments", { content: "" }), @@ -606,7 +612,7 @@ describe("GET /api/posts/[id]/comments", () => { return {}; }), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await commentGET( makeRequest("GET", "/api/posts/post-1/comments"), @@ -660,7 +666,7 @@ describe("POST /api/comments/[id]/reactions", () => { return {}; }), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await commentReactionPOST( makeRequest("POST", "/api/comments/c-1/reactions"), @@ -697,7 +703,7 @@ describe("POST /api/comments/[id]/reactions", () => { throw new Error(`Unexpected table ${table}`); }), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await commentReactionPOST( makeRequest("POST", "/api/comments/private-comment/reactions"), @@ -740,7 +746,7 @@ describe("DELETE /api/comments/[id]/reactions", () => { return {}; }), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await commentReactionDELETE( makeRequest("DELETE", "/api/comments/c-1/reactions"), @@ -780,7 +786,7 @@ describe("PATCH /api/comments/[id]", () => { }), }), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await commentPATCH( makeRequest("PATCH", "/api/comments/c-1", { content: "edited" }), @@ -815,7 +821,7 @@ describe("PATCH /api/comments/[id]", () => { }), }), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await commentPATCH( makeRequest("PATCH", "/api/comments/c-1", { content: "hack" }), @@ -837,7 +843,7 @@ describe("PATCH /api/comments/[id]", () => { }, from: vi.fn(), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await commentPATCH( makeRequest("PATCH", "/api/comments/c-1", { content: "x".repeat(501) }), @@ -867,7 +873,7 @@ describe("DELETE /api/comments/[id]", () => { }), }), }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await commentDELETE( makeRequest("DELETE", "/api/comments/c-1"), @@ -888,7 +894,7 @@ describe("DELETE /api/comments/[id]", () => { }), }, }; - (createClient as any).mockResolvedValue(client); + useClient(client); const res = await commentDELETE( makeRequest("DELETE", "/api/comments/c-1"), diff --git a/apps/web/__tests__/api/upload.test.ts b/apps/web/__tests__/api/upload.test.ts index 77007ade..d321d797 100644 --- a/apps/web/__tests__/api/upload.test.ts +++ b/apps/web/__tests__/api/upload.test.ts @@ -7,6 +7,7 @@ vi.mock("@/lib/supabase/server", () => ({ const mockServiceClient = { rpc: vi.fn(), + storage: undefined as Record | undefined, }; vi.mock("@/lib/supabase/service", () => ({ @@ -50,6 +51,7 @@ function mockSupabase(opts: { }, }; (createClient as any).mockResolvedValue(client); + mockServiceClient.storage = client.storage; return client; } @@ -63,6 +65,37 @@ function makeHeicBuffer(brand = "heic"): ArrayBuffer { return buf.buffer; } +function makeFileBuffer(name: string, type: string, size: number): ArrayBuffer { + const bytes = new Uint8Array(Math.max(size, 12)); + const ext = name.split(".").pop()?.toLowerCase(); + const inferredType = type && type !== "application/octet-stream" + ? type + : ext === "jpg" || ext === "jpeg" + ? "image/jpeg" + : ext === "png" + ? "image/png" + : ext === "webp" + ? "image/webp" + : ext === "gif" + ? "image/gif" + : ext === "heic" || ext === "heif" + ? "image/heic" + : type; + + if (inferredType === "image/jpeg") bytes.set([0xff, 0xd8, 0xff]); + if (inferredType === "image/png") bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + if (inferredType === "image/webp") { + bytes.set(new TextEncoder().encode("RIFF"), 0); + bytes.set(new TextEncoder().encode("WEBP"), 8); + } + if (inferredType === "image/gif") bytes.set(new TextEncoder().encode("GIF89a")); + if (inferredType === "image/heic" || inferredType === "image/heif") { + return makeHeicBuffer("heic"); + } + if (inferredType === "application/pdf") bytes.set(new TextEncoder().encode("%PDF-")); + return bytes.buffer; +} + /** * Build a mock NextRequest with a stubbed formData() method. * This avoids jsdom FormData + body streaming incompatibilities. @@ -77,7 +110,9 @@ function makeUploadRequest( name: file.name, type: file.type, size: file.size, - arrayBuffer: () => Promise.resolve(file.buffer ?? new ArrayBuffer(file.size)), + arrayBuffer: () => Promise.resolve( + file.buffer ?? makeFileBuffer(file.name, file.type, file.size), + ), }); } @@ -95,6 +130,7 @@ function makeUploadRequest( beforeEach(() => { vi.clearAllMocks(); + mockServiceClient.storage = undefined; resetRateLimiters(); mockServiceClient.rpc.mockResolvedValue({ data: [{ allowed: true, retry_after_seconds: 0 }], @@ -212,7 +248,7 @@ describe("POST /api/upload", () => { expect(json.url).toBeDefined(); }); - it("resolves MIME from uppercase .HEIC extension when browser sends octet-stream", async () => { + it("rejects a fake HEIC despite a matching extension", async () => { mockSupabase({ publicUrl: "https://cdn.example.com/user-1/abc.jpg" }); const res = await POST( @@ -220,13 +256,61 @@ describe("POST /api/upload", () => { name: "IMG_5678.HEIC", type: "application/octet-stream", size: 100, - // No magic bytes — relies on extension-based MIME resolution + buffer: new ArrayBuffer(100), }) ); const json = await res.json(); - expect(res.status).toBe(200); - expect(json.url).toBeDefined(); + expect(res.status).toBe(400); + expect(json.error).toContain("does not match"); + }); + + it("rejects HEIC bytes declared as another image type", async () => { + mockSupabase({}); + + const res = await POST( + makeUploadRequest({ + name: "spoofed.png", + type: "image/png", + size: 12, + buffer: makeHeicBuffer("heic"), + }) + ); + const json = await res.json(); + + expect(res.status).toBe(400); + expect(json.error).toContain("does not match"); + }); + + it("rejects HEIC conversion output over the bucket limit", async () => { + mockSupabase({}); + vi.mocked(convert).mockResolvedValueOnce(new ArrayBuffer(10 * 1024 * 1024 + 1)); + + const res = await POST( + makeUploadRequest({ name: "photo.heic", type: "image/heic", size: 12 }) + ); + const json = await res.json(); + + expect(res.status).toBe(400); + expect(json.error).toContain("10MB"); + }); + + it("rejects HTML disguised as a PNG", async () => { + mockSupabase({}); + + const bytes = new TextEncoder().encode(""); + const res = await POST( + makeUploadRequest({ + name: "payload.png", + type: "image/png", + size: bytes.byteLength, + buffer: bytes.buffer, + }) + ); + const json = await res.json(); + + expect(res.status).toBe(400); + expect(json.error).toContain("does not match"); }); it("resolves MIME from uppercase .JPG extension when browser sends empty type", async () => { @@ -323,16 +407,16 @@ describe("POST /api/upload", () => { expect(res.status).toBe(200); }); - it("rejects files over 20MB", async () => { + it("rejects post images over the storage bucket's 10MB limit", async () => { mockSupabase({}); const res = await POST( - makeUploadRequest({ name: "huge.jpg", type: "image/jpeg", size: 21 * 1024 * 1024 }) + makeUploadRequest({ name: "huge.jpg", type: "image/jpeg", size: 11 * 1024 * 1024 }) ); const json = await res.json(); expect(res.status).toBe(400); - expect(json.error).toContain("20MB"); + expect(json.error).toContain("10MB"); }); it("returns url on success", async () => { diff --git a/apps/web/__tests__/api/usage-submit.test.ts b/apps/web/__tests__/api/usage-submit.test.ts index 43144432..78e0cca8 100644 --- a/apps/web/__tests__/api/usage-submit.test.ts +++ b/apps/web/__tests__/api/usage-submit.test.ts @@ -9,6 +9,11 @@ vi.mock("@/lib/api/cli-auth", () => ({ verifyCliTokenWithRefresh: vi.fn(), })); +vi.mock("@/lib/api/active-cli-user", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, isActiveCliUser: vi.fn().mockResolvedValue(true) }; +}); + vi.mock("@/lib/supabase/service", () => ({ getServiceClient: vi.fn(), })); @@ -25,6 +30,10 @@ import { POST, aggregateDeviceRows } from "@/app/api/usage/submit/route"; import { captureServerActivationEvent } from "@/lib/analytics/server"; import { createClient } from "@/lib/supabase/server"; import { verifyCliToken, verifyCliTokenWithRefresh } from "@/lib/api/cli-auth"; +import { + CliIdentityUnavailableError, + isActiveCliUser, +} from "@/lib/api/active-cli-user"; import { getServiceClient } from "@/lib/supabase/service"; import { resetRateLimiters } from "@/lib/rate-limit"; @@ -200,6 +209,25 @@ describe("POST /api/usage/submit", () => { expect(json.results).toHaveLength(1); }); + it("returns 503 when CLI identity verification is unavailable", async () => { + (verifyCliToken as any).mockReturnValue("cli-user-id"); + vi.mocked(isActiveCliUser).mockRejectedValueOnce( + new CliIdentityUnavailableError(), + ); + mockSupabaseAuth(null); + const svc = mockServiceClient(); + + const res = await POST( + mockRequest( + { entries: [makeEntry(todayStr())], source: "cli" }, + { authorization: "Bearer some-token" }, + ), + ); + + expect(res.status).toBe(503); + expect(svc.from).not.toHaveBeenCalled(); + }); + it("handles Supabase session auth (cookie/web)", async () => { mockSupabaseAuth("web-user-id"); const svc = mockServiceClient(); diff --git a/apps/web/__tests__/flows/cli-push-flow.test.ts b/apps/web/__tests__/flows/cli-push-flow.test.ts index 9988427d..384575b8 100644 --- a/apps/web/__tests__/flows/cli-push-flow.test.ts +++ b/apps/web/__tests__/flows/cli-push-flow.test.ts @@ -22,6 +22,14 @@ vi.mock("@/lib/api/cli-auth", async (importOriginal) => { }; }); +vi.mock("@/lib/api/active-cli-user", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isActiveCliUser: vi.fn().mockResolvedValue(true), + }; +}); + const mockServiceClient = { from: vi.fn(), rpc: vi.fn(), diff --git a/apps/web/__tests__/flows/post-lifecycle.test.ts b/apps/web/__tests__/flows/post-lifecycle.test.ts index ae8083f3..46f1465f 100644 --- a/apps/web/__tests__/flows/post-lifecycle.test.ts +++ b/apps/web/__tests__/flows/post-lifecycle.test.ts @@ -12,6 +12,13 @@ vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn(() => mockSupabase), })); +vi.mock("@/lib/supabase/service", () => ({ + getServiceClient: vi.fn(() => ({ + from: (table: string) => mockSupabase.from(table), + rpc: vi.fn().mockResolvedValue({ data: null, error: null }), + })), +})); + vi.mock("@/lib/achievements", () => ({ checkAndAwardAchievements: vi.fn().mockResolvedValue(undefined), })); @@ -52,6 +59,7 @@ describe("Flow: Post Lifecycle", () => { beforeEach(() => { vi.clearAllMocks(); vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", "https://test.supabase.co"); + vi.stubEnv("SUPABASE_SECRET_KEY", "test-secret"); }); afterEach(() => { diff --git a/apps/web/__tests__/flows/privacy-visibility.test.ts b/apps/web/__tests__/flows/privacy-visibility.test.ts index aa4d951d..3937498f 100644 --- a/apps/web/__tests__/flows/privacy-visibility.test.ts +++ b/apps/web/__tests__/flows/privacy-visibility.test.ts @@ -22,6 +22,10 @@ vi.mock("@/lib/supabase/service", () => ({ getServiceClient: vi.fn(() => mockServiceClient), })); +vi.mock("@/lib/rate-limit", () => ({ + rateLimit: vi.fn().mockResolvedValue(null), +})); + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/apps/web/__tests__/flows/profile-and-contributions.test.ts b/apps/web/__tests__/flows/profile-and-contributions.test.ts index 66d1466a..5f45467b 100644 --- a/apps/web/__tests__/flows/profile-and-contributions.test.ts +++ b/apps/web/__tests__/flows/profile-and-contributions.test.ts @@ -22,6 +22,10 @@ vi.mock("@/lib/supabase/service", () => ({ getServiceClient: vi.fn(() => mockServiceClient), })); +vi.mock("@/lib/rate-limit", () => ({ + rateLimit: vi.fn().mockResolvedValue(null), +})); + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/apps/web/__tests__/flows/signup-to-feed.test.ts b/apps/web/__tests__/flows/signup-to-feed.test.ts index 146ea3f2..e11094b8 100644 --- a/apps/web/__tests__/flows/signup-to-feed.test.ts +++ b/apps/web/__tests__/flows/signup-to-feed.test.ts @@ -21,6 +21,10 @@ vi.mock("@/lib/supabase/service", () => ({ getServiceClient: vi.fn(() => mockServiceSupabase), })); +vi.mock("@/lib/rate-limit", () => ({ + rateLimit: vi.fn().mockResolvedValue(null), +})); + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/apps/web/__tests__/flows/social-interactions.test.ts b/apps/web/__tests__/flows/social-interactions.test.ts index 2980b69a..9bf0f418 100644 --- a/apps/web/__tests__/flows/social-interactions.test.ts +++ b/apps/web/__tests__/flows/social-interactions.test.ts @@ -11,6 +11,7 @@ const mockSupabase = { const mockServiceClient = { rpc: vi.fn(), + from: vi.fn((table: string) => mockSupabase.from(table)), }; vi.mock("@/lib/supabase/server", () => ({ diff --git a/apps/web/__tests__/flows/web-import-flow.test.ts b/apps/web/__tests__/flows/web-import-flow.test.ts index 385eea3a..12c26996 100644 --- a/apps/web/__tests__/flows/web-import-flow.test.ts +++ b/apps/web/__tests__/flows/web-import-flow.test.ts @@ -82,6 +82,7 @@ describe("Flow: Web JSON Import", () => { beforeEach(() => { vi.clearAllMocks(); + mockServiceClient.from.mockReset(); mockServiceClient.rpc.mockImplementation((fn: string) => { if (fn === "check_rate_limit") { return Promise.resolve({ data: [{ allowed: true, retry_after_seconds: 0 }], error: null }); @@ -164,6 +165,7 @@ describe("Flow: Web JSON Import", () => { (updateChain.select as ReturnType).mockReturnValue(updateChain); mockSupabase.from.mockImplementation(() => updateChain); + mockServiceClient.from.mockImplementation(() => updateChain); const { PATCH } = await import("@/app/api/posts/[id]/route"); const req = makeRequest("http://localhost:3000/api/posts/post-w1", { diff --git a/apps/web/__tests__/unit/active-cli-user.test.ts b/apps/web/__tests__/unit/active-cli-user.test.ts new file mode 100644 index 00000000..75498d34 --- /dev/null +++ b/apps/web/__tests__/unit/active-cli-user.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getUserById = vi.fn(); + +vi.mock("@/lib/supabase/service", () => ({ + getServiceClient: vi.fn(() => ({ + auth: { admin: { getUserById } }, + })), +})); + +import { + CliIdentityUnavailableError, + isActiveCliUser, +} from "@/lib/api/active-cli-user"; + +describe("isActiveCliUser", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("accepts an existing user without an active ban", async () => { + getUserById.mockResolvedValue({ + data: { user: { id: "user-1", banned_until: null } }, + error: null, + }); + + expect(await isActiveCliUser("user-1")).toBe(true); + }); + + it("rejects a deleted user", async () => { + getUserById.mockResolvedValue({ + data: { user: null }, + error: { message: "User not found", code: "user_not_found" }, + }); + + expect(await isActiveCliUser("deleted-user")).toBe(false); + }); + + it("rejects a user whose ban is still active", async () => { + getUserById.mockResolvedValue({ + data: { user: { id: "user-1", banned_until: "2999-01-01T00:00:00Z" } }, + error: null, + }); + + expect(await isActiveCliUser("user-1")).toBe(false); + }); + + it("fails closed when the identity provider returns an invalid ban date", async () => { + getUserById.mockResolvedValue({ + data: { user: { id: "user-1", banned_until: "invalid" } }, + error: null, + }); + + expect(await isActiveCliUser("user-1")).toBe(false); + }); + + it("distinguishes a transient identity provider error from an inactive user", async () => { + getUserById.mockResolvedValue({ + data: { user: null }, + error: { message: "Request timed out", code: "request_timeout" }, + }); + + await expect(isActiveCliUser("user-1")).rejects.toBeInstanceOf( + CliIdentityUnavailableError, + ); + }); + + it("surfaces identity provider connection failures", async () => { + getUserById.mockRejectedValue(new Error("Connection refused")); + + await expect(isActiveCliUser("user-1")).rejects.toBeInstanceOf( + CliIdentityUnavailableError, + ); + }); +}); diff --git a/apps/web/__tests__/unit/migration-safety.test.ts b/apps/web/__tests__/unit/migration-safety.test.ts index b7c83356..62d2f3eb 100644 --- a/apps/web/__tests__/unit/migration-safety.test.ts +++ b/apps/web/__tests__/unit/migration-safety.test.ts @@ -23,6 +23,51 @@ function getLatestMigrationMatching( return migrations.filter((m) => pattern.test(m.content)).at(-1); } +function hasAuthenticatedUsersUpdateGrant( + migrations: { name: string; content: string }[], +): boolean { + let tableUpdateGranted = false; + const columnUpdateGrants = new Set(); + const privilegeStatement = /\b(GRANT|REVOKE)\s+([\s\S]*?)\s+ON\s+public\.users\s+(?:TO|FROM)\s+([^;]+);/gi; + + for (const migration of migrations) { + const sql = migration.content.replace(/^\s*--.*$/gm, ""); + for (const match of sql.matchAll(privilegeStatement)) { + const [, action, privileges, roles] = match; + if (!/\bauthenticated\b/i.test(roles ?? "")) continue; + + const privilegeList = privileges ?? ""; + const columnUpdateMatches = privilegeList.matchAll( + /\b(?:UPDATE|ALL(?:\s+PRIVILEGES)?)\s*\(([^)]*)\)/gi, + ); + const columns = Array.from(columnUpdateMatches).flatMap((columnMatch) => + (columnMatch[1] ?? "") + .split(",") + .map((column) => column.trim().toLowerCase()) + .filter(Boolean), + ); + const tablePrivileges = privilegeList.replace( + /\b(?:UPDATE|ALL(?:\s+PRIVILEGES)?)\s*\([^)]*\)/gi, + "", + ); + const appliesTableWide = /\bALL(?:\s+PRIVILEGES)?\b|\bUPDATE\b/i.test( + tablePrivileges, + ); + if (!appliesTableWide && columns.length === 0) continue; + + if (action?.toUpperCase() === "GRANT") { + if (appliesTableWide) tableUpdateGranted = true; + for (const column of columns) columnUpdateGrants.add(column); + } else { + if (appliesTableWide) tableUpdateGranted = false; + for (const column of columns) columnUpdateGrants.delete(column); + } + } + } + + return tableUpdateGranted || columnUpdateGrants.size > 0; +} + describe("Migration safety", () => { const migrations = getAllMigrations(); @@ -30,6 +75,25 @@ describe("Migration safety", () => { expect(migrations.length).toBeGreaterThan(0); }); + it("tracks column-level users update grants and revokes", () => { + const columnGrant = { + name: "001_grant.sql", + content: "GRANT UPDATE (bio) ON public.users TO authenticated;", + }; + const columnRevoke = { + name: "002_revoke.sql", + content: "REVOKE UPDATE (bio) ON public.users FROM authenticated;", + }; + const tableGrant = { + name: "001_table_grant.sql", + content: "GRANT UPDATE ON public.users TO authenticated;", + }; + + expect(hasAuthenticatedUsersUpdateGrant([columnGrant])).toBe(true); + expect(hasAuthenticatedUsersUpdateGrant([columnGrant, columnRevoke])).toBe(false); + expect(hasAuthenticatedUsersUpdateGrant([tableGrant, columnRevoke])).toBe(true); + }); + it("handle_new_user() must always insert into public.users", () => { // Find all migrations that redefine handle_new_user const redefining = migrations.filter((m) => @@ -149,7 +213,7 @@ describe("Migration safety", () => { && ( /REVOKE\s+ALL\s+ON\s+public\.users/i.test(m.content) || /GRANT\s+SELECT\s+ON\s+public\.users/i.test(m.content) - || /GRANT\s+SELECT\s*\(/i.test(m.content) + || /GRANT\s+SELECT\s*\([^;]+\)\s+ON\s+public\.users/i.test(m.content) ) ); @@ -183,6 +247,137 @@ describe("Migration safety", () => { expect(/jsonb_build_object\s*\(/i.test(latest.content)).toBe(true); }); + it("routes sensitive writes through the server API", () => { + const latest = getLatestMigrationMatching( + migrations, + /REVOKE\s+UPDATE\s+ON\s+public\.users\s+FROM\s+authenticated/i, + ); + + expect(latest, "Expected an API write-privilege hardening migration").toBeTruthy(); + const content = latest!.content; + + expect(hasAuthenticatedUsersUpdateGrant(migrations)).toBe(false); + + expect(content).toMatch( + /REVOKE\s+INSERT,\s*UPDATE\s+ON\s+public\.daily_usage\s+FROM\s+authenticated/i, + ); + expect(content).toMatch( + /REVOKE\s+INSERT,\s*UPDATE\s+ON\s+public\.device_usage\s+FROM\s+authenticated/i, + ); + expect(content).toMatch( + /REVOKE\s+INSERT,\s*UPDATE,\s*DELETE\s+ON\s+public\.posts\s+FROM\s+authenticated/i, + ); + expect(content).not.toMatch( + /GRANT\s+UPDATE\s*\([^)]*\)\s+ON\s+public\.posts\s+TO\s+authenticated/i, + ); + expect(content).toMatch( + /REVOKE\s+INSERT,\s*DELETE\s+ON\s+public\.follows\s+FROM\s+authenticated/i, + ); + expect(content).toMatch( + /REVOKE\s+INSERT,\s*UPDATE,\s*DELETE\s+ON\s+public\.comments\s+FROM\s+authenticated/i, + ); + expect(content).toMatch( + /REVOKE\s+INSERT,\s*UPDATE\s+ON\s+public\.notifications\s+FROM\s+authenticated/i, + ); + expect(content).toMatch( + /GRANT\s+UPDATE\s*\(\s*read\s*\)\s+ON\s+public\.notifications\s+TO\s+authenticated/i, + ); + expect(content).toMatch( + /DROP\s+POLICY\s+IF\s+EXISTS\s+"Authenticated users can upload avatars"\s+ON\s+storage\.objects/i, + ); + }); + + it("does not expose internal daily usage metadata through publishable keys", () => { + const latest = getLatestMigrationMatching( + migrations, + /REVOKE\s+SELECT\s+ON\s+public\.daily_usage\s+FROM\s+anon,\s*authenticated/i, + ); + + expect(latest, "Expected a daily_usage column grant migration").toBeTruthy(); + const grant = latest!.content.match( + /GRANT\s+SELECT\s*\(([\s\S]*?)\)\s+ON\s+public\.daily_usage\s+TO\s+anon,\s*authenticated/i, + )?.[1]; + + expect(grant).toBeTruthy(); + expect(grant).not.toMatch(/raw_hash/i); + expect(grant).not.toMatch(/collector_meta/i); + }); + + it("locks privileged RPCs to their intended roles", () => { + const latest = getLatestMigrationMatching( + migrations, + /REVOKE\s+ALL\s+ON\s+FUNCTION\s+public\.admin_top_users\(int\)/i, + ); + + expect(latest, "Expected privileged RPC grants to be hardened").toBeTruthy(); + const content = latest!.content; + + expect(content).toMatch( + /REVOKE\s+ALL\s+ON\s+FUNCTION\s+public\.increment_streak_freezes\(uuid,\s*integer\)[\s\S]*FROM\s+PUBLIC,\s*anon,\s*authenticated/i, + ); + expect(content).toMatch( + /GRANT\s+EXECUTE\s+ON\s+FUNCTION\s+public\.increment_streak_freezes\(uuid,\s*integer\)[\s\S]*TO\s+service_role/i, + ); + expect(content).toMatch( + /REVOKE\s+ALL\s+ON\s+FUNCTION\s+public\.admin_top_users\(int\)[\s\S]*FROM\s+PUBLIC,\s*anon,\s*authenticated/i, + ); + expect(content).toMatch( + /GRANT\s+EXECUTE\s+ON\s+FUNCTION\s+public\.admin_top_users\(int\)\s+TO\s+service_role/i, + ); + expect(content).toMatch( + /REVOKE\s+ALL\s+ON\s+FUNCTION\s+public\.get_following_feed\(uuid,\s*int,\s*timestamptz\)[\s\S]*FROM\s+PUBLIC,\s*anon,\s*authenticated/i, + ); + }); + + it("authorizes streak visibility and bounds public RPC work", () => { + const latest = getLatestMigrationMatching( + migrations, + /CREATE\s+OR\s+REPLACE\s+FUNCTION\s+public\.calculate_user_streak/i, + ); + + expect(latest, "Expected calculate_user_streak hardening").toBeTruthy(); + const content = latest!.content; + + expect(content).toMatch(/u\.is_public\s*=\s*true/i); + expect(content).toMatch(/f\.follower_id\s*=\s*v_auth_user_id/i); + expect(content).toMatch(/RAISE\s+EXCEPTION\s+'Forbidden'/i); + expect(content).toMatch( + /DROP\s+FUNCTION\s+IF\s+EXISTS\s+public\.calculate_user_streak\(uuid\)/i, + ); + expect(content).toMatch(/cardinality\(p_user_ids\)\s*>\s*100/i); + const batchFunction = content.match( + /CREATE\s+OR\s+REPLACE\s+FUNCTION\s+public\.calculate_streaks_batch[\s\S]*?\$\$;/i, + )?.[0]; + expect(batchFunction).toBeTruthy(); + expect(batchFunction).toMatch(/FOREACH\s+v_user_id\s+IN\s+ARRAY\s+p_user_ids/i); + expect(batchFunction).toMatch( + /WHEN\s+SQLSTATE\s+'42501'[\s\S]*streak\s*:=\s*0[\s\S]*RETURN\s+NEXT/i, + ); + expect(content).toMatch( + /LEAST\(GREATEST\(COALESCE\(streak_freezes,\s*0\),\s*0\),\s*7\)/i, + ); + expect(content).not.toMatch( + /v_freeze_days\s*:=\s*[^;]*p_freeze_days/i, + ); + }); + + it("bounds get_feed and requires usage ownership", () => { + const redefining = migrations.filter((m) => + /CREATE\s+OR\s+REPLACE\s+FUNCTION\s+public\.get_feed/i.test(m.content) + ); + const latest = redefining.at(-1); + + expect(latest).toBeTruthy(); + expect(latest!.content).toMatch( + /v_limit\s+integer\s*:=\s*LEAST\(GREATEST\(COALESCE\(p_limit,\s*20\),\s*1\),\s*100\)/i, + ); + expect(latest!.content).toMatch(/d\.user_id\s*=\s*p\.user_id/i); + expect(latest!.content).toMatch(/'is_verified',\s*d\.is_verified/i); + expect(latest!.content).not.toMatch(/to_jsonb\(d\.\*\)/i); + expect(latest!.content).not.toMatch(/'raw_hash'/i); + expect(latest!.content).not.toMatch(/'collector_meta'/i); + }); + it("latest cli_auth_codes hardening removes public grants and pending-code select policies", () => { const latest = getLatestMigrationMatching( migrations, diff --git a/apps/web/__tests__/unit/types.test.ts b/apps/web/__tests__/unit/types.test.ts index 00c21f70..0674a6fb 100644 --- a/apps/web/__tests__/unit/types.test.ts +++ b/apps/web/__tests__/unit/types.test.ts @@ -62,7 +62,7 @@ describe("type shapes", () => { expect(user.is_public).toBe(false); }); - it("DailyUsage object has expected fields", () => { + it("DailyUsage allows public rows without internal metadata", () => { const usage: DailyUsage = { id: "d1", user_id: "u1", @@ -78,7 +78,6 @@ describe("type shapes", () => { model_breakdown: null, session_count: 3, is_verified: true, - raw_hash: "abc123", created_at: "2025-06-01T00:00:00Z", updated_at: "2025-06-01T00:00:00Z", }; diff --git a/apps/web/app/(app)/post/[id]/page.tsx b/apps/web/app/(app)/post/[id]/page.tsx index ee086119..063032ac 100644 --- a/apps/web/app/(app)/post/[id]/page.tsx +++ b/apps/web/app/(app)/post/[id]/page.tsx @@ -6,6 +6,7 @@ import { PostEditor } from "@/components/app/post/PostEditor"; import { PostSharePanel } from "@/components/app/post/PostSharePanel"; import { loadPostComments } from "@/lib/comments"; import { firstRelation } from "@/lib/utils/first-relation"; +import { PUBLIC_DAILY_USAGE_FIELDS } from "@/lib/data/public-daily-usage"; import { formatCurrency } from "@/lib/utils/format"; import type { AggregateCount, FeedPostRow, UserSummary } from "@/types"; import type { Metadata } from "next"; @@ -120,7 +121,7 @@ export default async function PostDetailPage({ ` *, user:users!posts_user_id_fkey(id, username, display_name, bio, avatar_url, country, region, link, github_username, is_public), - daily_usage:daily_usage!posts_daily_usage_id_fkey(*), + daily_usage:daily_usage!posts_daily_usage_id_fkey(${PUBLIC_DAILY_USAGE_FIELDS}), kudos_count:kudos(count), comment_count:comments(count) ` diff --git a/apps/web/app/api/app/right-sidebar/route.ts b/apps/web/app/api/app/right-sidebar/route.ts index d2ba89f0..0209b17d 100644 --- a/apps/web/app/api/app/right-sidebar/route.ts +++ b/apps/web/app/api/app/right-sidebar/route.ts @@ -50,6 +50,7 @@ export async function GET() { .from("users") .select("id, username, avatar_url, bio") .eq("is_pinned_suggestion", true) + .eq("is_public", true) .not("id", "in", excludeFilter), service .from("daily_usage") diff --git a/apps/web/app/api/cli/dashboard/route.ts b/apps/web/app/api/cli/dashboard/route.ts index 694bb50c..0fb2de36 100644 --- a/apps/web/app/api/cli/dashboard/route.ts +++ b/apps/web/app/api/cli/dashboard/route.ts @@ -1,5 +1,9 @@ import { NextResponse } from "next/server"; import { verifyCliTokenWithRefresh } from "@/lib/api/cli-auth"; +import { + CliIdentityUnavailableError, + isActiveCliUser, +} from "@/lib/api/active-cli-user"; import { getServiceClient } from "@/lib/supabase/service"; export async function GET(request: Request) { @@ -9,6 +13,21 @@ export async function GET(request: Request) { if (!auth) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + + try { + if (!(await isActiveCliUser(auth.userId))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + } catch (error) { + if (error instanceof CliIdentityUnavailableError) { + return NextResponse.json( + { error: "Identity verification unavailable" }, + { status: 503 }, + ); + } + throw error; + } + const userId = auth.userId; const db = getServiceClient(); diff --git a/apps/web/app/api/comments/[id]/reactions/route.ts b/apps/web/app/api/comments/[id]/reactions/route.ts index e35b2be4..551678d7 100644 --- a/apps/web/app/api/comments/[id]/reactions/route.ts +++ b/apps/web/app/api/comments/[id]/reactions/route.ts @@ -1,5 +1,6 @@ import { NextResponse, type NextRequest } from "next/server"; import { createClient } from "@/lib/supabase/server"; +import { getServiceClient } from "@/lib/supabase/service"; import { rateLimit } from "@/lib/rate-limit"; type RouteContext = { params: Promise<{ id: string }> }; @@ -32,7 +33,7 @@ export async function POST(_request: NextRequest, context: RouteContext) { return NextResponse.json({ error: "Comment not found" }, { status: 404 }); } - const { error } = await supabase.from("comment_reactions").insert({ + const { error } = await getServiceClient().from("comment_reactions").insert({ user_id: user.id, comment_id: id, }); @@ -60,7 +61,7 @@ export async function DELETE(_request: NextRequest, context: RouteContext) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - await supabase + await getServiceClient() .from("comment_reactions") .delete() .eq("user_id", user.id) diff --git a/apps/web/app/api/comments/[id]/route.ts b/apps/web/app/api/comments/[id]/route.ts index d77e6969..99b22a64 100644 --- a/apps/web/app/api/comments/[id]/route.ts +++ b/apps/web/app/api/comments/[id]/route.ts @@ -1,5 +1,6 @@ import { NextResponse, type NextRequest } from "next/server"; import { createClient } from "@/lib/supabase/server"; +import { getServiceClient } from "@/lib/supabase/service"; import { rateLimit } from "@/lib/rate-limit"; type RouteContext = { params: Promise<{ id: string }> }; @@ -27,7 +28,7 @@ export async function PATCH(request: NextRequest, context: RouteContext) { ); } - const { data: comment, error } = await supabase + const { data: comment, error } = await getServiceClient() .from("comments") .update({ content }) .eq("id", id) @@ -56,7 +57,7 @@ export async function DELETE(_request: NextRequest, context: RouteContext) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { error } = await supabase + const { error } = await getServiceClient() .from("comments") .delete() .eq("id", id) diff --git a/apps/web/app/api/company-suggestions/route.ts b/apps/web/app/api/company-suggestions/route.ts index 31fb53b8..852d1366 100644 --- a/apps/web/app/api/company-suggestions/route.ts +++ b/apps/web/app/api/company-suggestions/route.ts @@ -1,5 +1,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { createClient } from "@/lib/supabase/server"; +import { getServiceClient } from "@/lib/supabase/service"; +import { rateLimit } from "@/lib/rate-limit"; const MAX_SUBMISSIONS_PER_24H = 5; @@ -77,7 +79,12 @@ export async function POST(request: NextRequest) { ); } - // Rate limit + const limited = await rateLimit("company-suggestion", user.id, { + limit: MAX_SUBMISSIONS_PER_24H, + windowSeconds: 24 * 60 * 60, + }); + if (limited) return limited; + const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); const { count, error: countError } = await supabase .from("company_suggestions") @@ -96,7 +103,7 @@ export async function POST(request: NextRequest) { ); } - const { data, error } = await supabase + const { data, error } = await getServiceClient() .from("company_suggestions") .insert({ user_id: user.id, diff --git a/apps/web/app/api/feed/route.ts b/apps/web/app/api/feed/route.ts index 7428971f..c7d96893 100644 --- a/apps/web/app/api/feed/route.ts +++ b/apps/web/app/api/feed/route.ts @@ -15,7 +15,10 @@ export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; const cursor = searchParams.get("cursor"); - const limit = Math.min(Number(searchParams.get("limit") ?? 20), 50); + const requestedLimit = Number(searchParams.get("limit") ?? 20); + const limit = Number.isFinite(requestedLimit) + ? Math.min(Math.max(Math.trunc(requestedLimit), 1), 50) + : 20; const type = searchParams.get("type") ?? "global"; // Unauthenticated users can only access global and user (profile) feeds diff --git a/apps/web/app/api/follow/[username]/route.ts b/apps/web/app/api/follow/[username]/route.ts index ea77407e..5308321e 100644 --- a/apps/web/app/api/follow/[username]/route.ts +++ b/apps/web/app/api/follow/[username]/route.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { after } from "@/lib/utils/after"; import { createClient } from "@/lib/supabase/server"; +import { getServiceClient } from "@/lib/supabase/service"; import { rateLimit } from "@/lib/rate-limit"; type RouteContext = { params: Promise<{ username: string }> }; @@ -36,7 +37,8 @@ export async function POST(_request: NextRequest, context: RouteContext) { ); } - const { error } = await supabase.from("follows").insert({ + const db = getServiceClient(); + const { error } = await db.from("follows").insert({ follower_id: user.id, following_id: target.id, }); @@ -51,7 +53,7 @@ export async function POST(_request: NextRequest, context: RouteContext) { // Insert follow notification after the response is sent after(async () => { - await supabase.from("notifications").insert({ + await db.from("notifications").insert({ user_id: target.id, actor_id: user.id, type: "follow", @@ -82,7 +84,7 @@ export async function DELETE(_request: NextRequest, context: RouteContext) { return NextResponse.json({ error: "User not found" }, { status: 404 }); } - await supabase + await getServiceClient() .from("follows") .delete() .eq("follower_id", user.id) diff --git a/apps/web/app/api/leaderboard/route.ts b/apps/web/app/api/leaderboard/route.ts index e5751531..87bf8871 100644 --- a/apps/web/app/api/leaderboard/route.ts +++ b/apps/web/app/api/leaderboard/route.ts @@ -23,7 +23,10 @@ export async function GET(request: NextRequest) { const period = (searchParams.get("period") ?? "week") as Period; const region = searchParams.get("region"); const cursor = searchParams.get("cursor"); - const limit = Math.min(Number(searchParams.get("limit") ?? 50), 100); + const requestedLimit = Number(searchParams.get("limit") ?? 50); + const limit = Number.isFinite(requestedLimit) + ? Math.min(Math.max(Math.trunc(requestedLimit), 1), 100) + : 50; if (!VALID_PERIODS.includes(period)) { return NextResponse.json({ error: "Invalid period" }, { status: 400 }); diff --git a/apps/web/app/api/messages/route.ts b/apps/web/app/api/messages/route.ts index bcd0e7f8..d8f3bd0f 100644 --- a/apps/web/app/api/messages/route.ts +++ b/apps/web/app/api/messages/route.ts @@ -299,13 +299,14 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Cannot message yourself" }, { status: 400 }); } + const db = getServiceClient(); const [senderRes, messageRes] = await Promise.all([ supabase .from("users") .select("id, username, avatar_url, display_name") .eq("id", user.id) .single(), - supabase + db .from("direct_messages") .insert({ sender_id: user.id, diff --git a/apps/web/app/api/posts/[id]/comments/route.ts b/apps/web/app/api/posts/[id]/comments/route.ts index 128935fb..8a41c1f5 100644 --- a/apps/web/app/api/posts/[id]/comments/route.ts +++ b/apps/web/app/api/posts/[id]/comments/route.ts @@ -128,7 +128,8 @@ export async function POST(request: NextRequest, context: RouteContext) { } } - const { data: comment, error } = await supabase + const db = getServiceClient(); + const { data: comment, error } = await db .from("comments") .insert({ user_id: user.id, @@ -147,7 +148,7 @@ export async function POST(request: NextRequest, context: RouteContext) { after(async () => { // Insert comment notification (skip self-comment) if (post && post.user_id !== user.id) { - await supabase.from("notifications").insert({ + await db.from("notifications").insert({ user_id: post.user_id, actor_id: user.id, type: "comment", @@ -172,7 +173,7 @@ export async function POST(request: NextRequest, context: RouteContext) { // Mention notifications (de-dup: skip self and post owner) const mentionedUsernames = parseMentions(content); if (mentionedUsernames.length > 0) { - const { data: mentionedUsers } = await supabase + const { data: mentionedUsers } = await db .from("users") .select("id, username") .in("username", mentionedUsernames); @@ -189,7 +190,7 @@ export async function POST(request: NextRequest, context: RouteContext) { })); if (mentionNotifs.length > 0) { - await supabase.from("notifications").insert(mentionNotifs); + await db.from("notifications").insert(mentionNotifs); } // Fire mention emails (one per mentioned user) diff --git a/apps/web/app/api/posts/[id]/kudos/route.ts b/apps/web/app/api/posts/[id]/kudos/route.ts index 3491ed8d..22daa774 100644 --- a/apps/web/app/api/posts/[id]/kudos/route.ts +++ b/apps/web/app/api/posts/[id]/kudos/route.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { after } from "@/lib/utils/after"; import { createClient } from "@/lib/supabase/server"; +import { getServiceClient } from "@/lib/supabase/service"; import { checkAndAwardAchievements } from "@/lib/achievements"; import { rateLimit } from "@/lib/rate-limit"; @@ -34,7 +35,8 @@ export async function POST(_request: NextRequest, context: RouteContext) { return NextResponse.json({ error: "Post not found" }, { status: 404 }); } - const { error } = await supabase.from("kudos").insert({ + const db = getServiceClient(); + const { error } = await db.from("kudos").insert({ user_id: user.id, post_id: id, }); @@ -48,7 +50,7 @@ export async function POST(_request: NextRequest, context: RouteContext) { after(async () => { // Insert kudos notification (skip self-kudos) if (post && post.user_id !== user.id) { - await supabase.from("notifications").insert({ + await db.from("notifications").insert({ user_id: post.user_id, actor_id: user.id, type: "kudos", @@ -83,7 +85,7 @@ export async function DELETE(_request: NextRequest, context: RouteContext) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - await supabase + await getServiceClient() .from("kudos") .delete() .eq("user_id", user.id) diff --git a/apps/web/app/api/posts/[id]/route.ts b/apps/web/app/api/posts/[id]/route.ts index 2cf269f5..15d65376 100644 --- a/apps/web/app/api/posts/[id]/route.ts +++ b/apps/web/app/api/posts/[id]/route.ts @@ -6,6 +6,7 @@ import { isFirstPartyPublicStorageUrl } from "@/lib/storage"; import { parseMentions } from "@/lib/utils/mentions"; import { sendNotificationEmail } from "@/lib/email/send-comment-email"; import { checkAndAwardAchievements } from "@/lib/achievements"; +import { PUBLIC_DAILY_USAGE_FIELDS } from "@/lib/data/public-daily-usage"; type RouteContext = { params: Promise<{ id: string }> }; @@ -23,7 +24,7 @@ export async function GET(_request: NextRequest, context: RouteContext) { ` *, user:users!posts_user_id_fkey(id, username, display_name, bio, avatar_url, country, region, link, github_username, is_public), - daily_usage:daily_usage!posts_daily_usage_id_fkey(*), + daily_usage:daily_usage!posts_daily_usage_id_fkey(${PUBLIC_DAILY_USAGE_FIELDS}), kudos_count:kudos(count), comment_count:comments(count) ` @@ -130,7 +131,7 @@ export async function PATCH(request: NextRequest, context: RouteContext) { return NextResponse.json({ error: "No fields to update" }, { status: 400 }); } - const { data: post, error } = await supabase + const { data: post, error } = await getServiceClient() .from("posts") .update(updates) .eq("id", id) @@ -276,7 +277,7 @@ export async function DELETE(_request: NextRequest, context: RouteContext) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { error } = await supabase + const { error } = await getServiceClient() .from("posts") .delete() .eq("id", id) diff --git a/apps/web/app/api/prompts/route.ts b/apps/web/app/api/prompts/route.ts index e138b6e7..60956be1 100644 --- a/apps/web/app/api/prompts/route.ts +++ b/apps/web/app/api/prompts/route.ts @@ -1,5 +1,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { createClient } from "@/lib/supabase/server"; +import { getServiceClient } from "@/lib/supabase/service"; +import { rateLimit } from "@/lib/rate-limit"; const MAX_PROMPT_LENGTH = 2000; const MIN_PROMPT_LENGTH = 10; @@ -76,6 +78,13 @@ export async function POST(request: NextRequest) { if (body.anonymous !== undefined && typeof body.anonymous !== "boolean") { return NextResponse.json({ error: "anonymous must be a boolean" }, { status: 400 }); } + + const limited = await rateLimit("prompt-submission", user.id, { + limit: MAX_SUBMISSIONS_PER_24H, + windowSeconds: 24 * 60 * 60, + }); + if (limited) return limited; + const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); const { count, error: countError } = await supabase @@ -95,7 +104,7 @@ export async function POST(request: NextRequest) { ); } - const { data, error } = await supabase + const { data, error } = await getServiceClient() .from("prompt_submissions") .insert({ user_id: user.id, diff --git a/apps/web/app/api/search/route.ts b/apps/web/app/api/search/route.ts index acc88d8f..af94f67e 100644 --- a/apps/web/app/api/search/route.ts +++ b/apps/web/app/api/search/route.ts @@ -4,35 +4,50 @@ import { createClient } from "@/lib/supabase/server"; // Safe public fields only — never expose email, private settings, etc. const PUBLIC_USER_FIELDS = "id, username, display_name, bio, avatar_url, is_public"; -/** Strip characters that could break PostgREST filter syntax */ -function sanitizeFilter(s: string): string { - return s.replace(/[,()\\@]/g, ""); +function quoteIlikePattern(value: string): string { + const pattern = `%${value.replaceAll("%", "\\%").replaceAll("_", "\\_")}%`; + const escapedValue = pattern.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); + return `"${escapedValue}"`; } export async function GET(request: NextRequest) { const supabase = await createClient(); const q = request.nextUrl.searchParams.get("q") ?? ""; - const limit = Math.min( - Number(request.nextUrl.searchParams.get("limit") ?? 20), - 50 - ); + const requestedLimit = Number(request.nextUrl.searchParams.get("limit") ?? 20); + const limit = Number.isFinite(requestedLimit) + ? Math.min(Math.max(Math.trunc(requestedLimit), 1), 50) + : 20; - if (q.length < 2) { + if (q.length < 2 || q.length > 64) { return NextResponse.json( - { error: "Query must be at least 2 characters" }, + { error: "Query must be between 2 and 64 characters" }, { status: 400 } ); } - const safe = sanitizeFilter(q); + const normalizedQuery = q.normalize("NFKC").trim(); + if (normalizedQuery.includes("*")) { + return NextResponse.json( + { error: "Query contains unsupported characters" }, + { status: 400 }, + ); + } + const searchableCharacters = normalizedQuery.match(/[\p{L}\p{N}_-]/gu)?.length ?? 0; + if (searchableCharacters < 2) { + return NextResponse.json( + { error: "Query must contain at least 2 searchable characters" }, + { status: 400 }, + ); + } + const pattern = quoteIlikePattern(normalizedQuery); // Search by username, display name, or github_username const { data: users, error } = await supabase .from("users") .select(PUBLIC_USER_FIELDS) .eq("is_public", true) - .or(`username.ilike.%${safe}%,display_name.ilike.%${safe}%,github_username.ilike.%${safe}%`) + .or(`username.ilike.${pattern},display_name.ilike.${pattern},github_username.ilike.${pattern}`) .limit(limit); if (error) { diff --git a/apps/web/app/api/upload/route.ts b/apps/web/app/api/upload/route.ts index c9a1144a..8fb39b5f 100644 --- a/apps/web/app/api/upload/route.ts +++ b/apps/web/app/api/upload/route.ts @@ -1,5 +1,6 @@ import { NextResponse, type NextRequest } from "next/server"; import { createClient } from "@/lib/supabase/server"; +import { getServiceClient } from "@/lib/supabase/service"; import { rateLimit } from "@/lib/rate-limit"; import { randomUUID } from "node:crypto"; import convert from "heic-convert"; @@ -34,7 +35,7 @@ const BUCKET_CONFIG: Record= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) { + return "image/jpeg"; + } + if ( + buf.length >= 8 + && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) + ) { + return "image/png"; + } + if ( + buf.length >= 12 + && buf.toString("ascii", 0, 4) === "RIFF" + && buf.toString("ascii", 8, 12) === "WEBP" + ) { + return "image/webp"; + } + const gifHeader = buf.toString("ascii", 0, 6); + if (gifHeader === "GIF87a" || gifHeader === "GIF89a") { + return "image/gif"; + } + return null; +} + function isImageType(mime: string): boolean { return mime.startsWith("image/") || mime === "application/octet-stream" || mime === ""; } @@ -131,8 +156,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "No file provided" }, { status: 400 }); } + const maxMB = Math.round(config.maxSize / (1024 * 1024)); if (file.size > config.maxSize) { - const maxMB = Math.round(config.maxSize / (1024 * 1024)); return NextResponse.json( { error: `File too large. Maximum size is ${maxMB}MB` }, { status: 400 } @@ -151,7 +176,8 @@ export async function POST(request: NextRequest) { let ext: string; // Detect HEIC by magic bytes OR MIME type — iOS sometimes mislabels HEIC files - const isHeic = HEIC_MIME_TYPES.includes(mimeType) || isHeicByMagicBytes(buffer); + const hasHeicSignature = isHeicByMagicBytes(buffer); + const isHeic = HEIC_MIME_TYPES.includes(mimeType) || hasHeicSignature; if (!config.allowedTypes.includes(mimeType) && !isHeic) { return NextResponse.json( @@ -160,7 +186,34 @@ export async function POST(request: NextRequest) { ); } - if (isHeic) { + if (HEIC_MIME_TYPES.includes(mimeType) && !hasHeicSignature) { + return NextResponse.json( + { error: "File content does not match the declared image type" }, + { status: 400 }, + ); + } + + const acceptsDetectedHeic = HEIC_MIME_TYPES.includes(mimeType) + || mimeType === "application/octet-stream" + || mimeType === ""; + if (hasHeicSignature && !acceptsDetectedHeic) { + return NextResponse.json( + { error: "File content does not match the declared image type" }, + { status: 400 }, + ); + } + + if (!hasHeicSignature && mimeType.startsWith("image/")) { + const detectedMime = detectImageMime(buffer); + if (detectedMime !== mimeType) { + return NextResponse.json( + { error: "File content does not match the declared image type" }, + { status: 400 }, + ); + } + } + + if (hasHeicSignature) { try { const jpegBuf = await (convert as unknown as HeicConverter)({ buffer, format: "JPEG", quality: 0.9 }); buffer = ArrayBuffer.isView(jpegBuf) @@ -189,9 +242,17 @@ export async function POST(request: NextRequest) { ext = getExtension(mimeType, file.name); } + if (buffer.length > config.maxSize) { + return NextResponse.json( + { error: `File too large. Maximum size is ${maxMB}MB` }, + { status: 400 }, + ); + } + const fileName = `${user.id}/${randomUUID()}.${ext}`; - const { error: uploadError } = await supabase.storage + const storage = getServiceClient().storage; + const { error: uploadError } = await storage .from(bucket) .upload(fileName, buffer, { contentType, @@ -207,7 +268,7 @@ export async function POST(request: NextRequest) { const { data: { publicUrl }, - } = supabase.storage.from(bucket).getPublicUrl(fileName); + } = storage.from(bucket).getPublicUrl(fileName); if (bucket === "dm-attachments") { return NextResponse.json({ diff --git a/apps/web/app/api/usage/submit/route.ts b/apps/web/app/api/usage/submit/route.ts index 0d5f1770..7b06bc81 100644 --- a/apps/web/app/api/usage/submit/route.ts +++ b/apps/web/app/api/usage/submit/route.ts @@ -3,6 +3,10 @@ import { after } from "@/lib/utils/after"; import { captureServerActivationEvent } from "@/lib/analytics/server"; import { createClient } from "@/lib/supabase/server"; import { verifyCliTokenWithRefresh } from "@/lib/api/cli-auth"; +import { + CliIdentityUnavailableError, + isActiveCliUser, +} from "@/lib/api/active-cli-user"; import { getServiceClient } from "@/lib/supabase/service"; import { checkAndAwardAchievements } from "@/lib/achievements"; import { rateLimit } from "@/lib/rate-limit"; @@ -361,6 +365,7 @@ async function resolveAuthContext(request: Request): Promise const authHeader = request.headers.get("authorization"); const cliAuth = verifyCliTokenWithRefresh(authHeader); if (cliAuth) { + if (!(await isActiveCliUser(cliAuth.userId))) return null; return { userId: cliAuth.userId, username: cliAuth.username, @@ -481,7 +486,18 @@ export async function POST(request: Request) { return NextResponse.json({ error: collectorValidationError }, { status: 400 }); } - const auth = await resolveAuthContext(request); + let auth: AuthContext | null; + try { + auth = await resolveAuthContext(request); + } catch (error) { + if (error instanceof CliIdentityUnavailableError) { + return NextResponse.json( + { error: "Identity verification unavailable" }, + { status: 503 }, + ); + } + throw error; + } if (!auth) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/apps/web/app/api/users/me/route.ts b/apps/web/app/api/users/me/route.ts index 4656b357..3c81af1f 100644 --- a/apps/web/app/api/users/me/route.ts +++ b/apps/web/app/api/users/me/route.ts @@ -7,6 +7,8 @@ import { COUNTRY_TO_REGION } from "@/lib/constants/regions"; import { sendWelcomeEmail } from "@/lib/email/send-welcome-email"; import { attributeReferral } from "@/lib/referral"; import { resolveTeamFavicon } from "@/lib/team-favicon"; +import { isAllowedAvatarUrl } from "@/lib/storage"; +import { rateLimit } from "@/lib/rate-limit"; const ALLOWED_FIELDS = [ "username", @@ -27,6 +29,9 @@ const ALLOWED_FIELDS = [ const BIO_MAX_LENGTH = 160; const HEARD_ABOUT_MAX_LENGTH = 500; +const DISPLAY_NAME_MAX_LENGTH = 100; +const PROFILE_URL_MAX_LENGTH = 2048; +const GITHUB_USERNAME_PATTERN = /^(?!-)[a-zA-Z0-9-]{1,39}(? PROFILE_URL_MAX_LENGTH) { + throw new Error(`Profile link must be at most ${PROFILE_URL_MAX_LENGTH} characters`); + } let parsed: URL; try { @@ -100,7 +108,19 @@ export async function PATCH(request: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const body = await request.json(); + const limited = await rateLimit("profile-update", user.id, { limit: 20 }); + if (limited) return limited; + + let parsedBody: unknown; + try { + parsedBody = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + if (!parsedBody || typeof parsedBody !== "object" || Array.isArray(parsedBody)) { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } + const body = parsedBody as Record; const updates: Record = {}; for (const field of ALLOWED_FIELDS) { @@ -111,8 +131,10 @@ export async function PATCH(request: NextRequest) { // Validate username if provided if (updates.username !== undefined) { - const username = updates.username as string; - if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) { + if ( + typeof updates.username !== "string" + || !/^[a-zA-Z0-9_]{3,20}$/.test(updates.username) + ) { return NextResponse.json( { error: "Username must be 3-20 alphanumeric characters or underscores" }, { status: 400 } @@ -120,16 +142,29 @@ export async function PATCH(request: NextRequest) { } } - // Validate bio length - if ( - updates.bio !== undefined && - typeof updates.bio === "string" && - updates.bio.length > BIO_MAX_LENGTH - ) { - return NextResponse.json( - { error: `Bio must be at most ${BIO_MAX_LENGTH} characters` }, - { status: 400 } - ); + if (updates.display_name !== undefined) { + if ( + updates.display_name !== null + && (typeof updates.display_name !== "string" + || updates.display_name.length > DISPLAY_NAME_MAX_LENGTH) + ) { + return NextResponse.json( + { error: `Display name must be text of at most ${DISPLAY_NAME_MAX_LENGTH} characters` }, + { status: 400 }, + ); + } + } + + if (updates.bio !== undefined) { + if ( + updates.bio !== null + && (typeof updates.bio !== "string" || updates.bio.length > BIO_MAX_LENGTH) + ) { + return NextResponse.json( + { error: `Bio must be text of at most ${BIO_MAX_LENGTH} characters` }, + { status: 400 } + ); + } } if (updates.heard_about !== undefined) { @@ -168,6 +203,61 @@ export async function PATCH(request: NextRequest) { } } + if (updates.avatar_url !== undefined) { + if ( + updates.avatar_url !== null + && (typeof updates.avatar_url !== "string" + || updates.avatar_url.length > PROFILE_URL_MAX_LENGTH + || !isAllowedAvatarUrl(updates.avatar_url)) + ) { + return NextResponse.json( + { error: "Avatar URL must use an approved image provider" }, + { status: 400 }, + ); + } + } + + if (updates.github_username !== undefined) { + if ( + updates.github_username !== null + && (typeof updates.github_username !== "string" + || !GITHUB_USERNAME_PATTERN.test(updates.github_username)) + ) { + return NextResponse.json( + { error: "GitHub username is invalid" }, + { status: 400 }, + ); + } + } + + if (updates.timezone !== undefined) { + if (typeof updates.timezone !== "string" || updates.timezone.length > 64) { + return NextResponse.json({ error: "Timezone is invalid" }, { status: 400 }); + } + const timezone = updates.timezone || "UTC"; + try { + new Intl.DateTimeFormat("en-US", { timeZone: timezone }); + } catch { + return NextResponse.json({ error: "Timezone is invalid" }, { status: 400 }); + } + updates.timezone = timezone; + } + + for (const field of [ + "is_public", + "onboarding_completed", + "email_notifications", + "email_mention_notifications", + "email_dm_notifications", + ] as const) { + if (updates[field] !== undefined && typeof updates[field] !== "boolean") { + return NextResponse.json( + { error: `${field} must be a boolean` }, + { status: 400 }, + ); + } + } + // Team affiliation: validate URL and derive cached favicon URL via the // resolver. team_favicon_url is server-derived only — never accepted from // the client body. @@ -175,7 +265,10 @@ export async function PATCH(request: NextRequest) { if (body.team_url === null || body.team_url === "") { updates.team_url = null; updates.team_favicon_url = null; - } else if (typeof body.team_url === "string") { + } else if ( + typeof body.team_url === "string" + && body.team_url.length <= PROFILE_URL_MAX_LENGTH + ) { const result = await resolveTeamFavicon(body.team_url); if (!result.ok) { return NextResponse.json( @@ -195,7 +288,15 @@ export async function PATCH(request: NextRequest) { // Auto-derive region from country if (updates.country !== undefined) { - const country = updates.country as string | null; + if ( + updates.country !== null + && (typeof updates.country !== "string" + || !(updates.country.toUpperCase() in COUNTRY_TO_REGION)) + ) { + return NextResponse.json({ error: "Country is invalid" }, { status: 400 }); + } + const country = updates.country ? updates.country.toUpperCase() : null; + updates.country = country; if (country) { updates.region = COUNTRY_TO_REGION[country.toUpperCase()] ?? null; } else { diff --git a/apps/web/lib/api/active-cli-user.ts b/apps/web/lib/api/active-cli-user.ts new file mode 100644 index 00000000..bca78081 --- /dev/null +++ b/apps/web/lib/api/active-cli-user.ts @@ -0,0 +1,28 @@ +import { getServiceClient } from "@/lib/supabase/service"; + +export class CliIdentityUnavailableError extends Error { + constructor() { + super("CLI identity verification is unavailable"); + this.name = "CliIdentityUnavailableError"; + } +} + +export async function isActiveCliUser(userId: string): Promise { + try { + const { data, error } = await getServiceClient().auth.admin.getUserById(userId); + if (error) { + if (error.code === "user_not_found") return false; + throw new CliIdentityUnavailableError(); + } + if (!data.user) return false; + + const bannedUntil = data.user.banned_until; + if (!bannedUntil) return true; + + const bannedUntilMs = Date.parse(bannedUntil); + return Number.isFinite(bannedUntilMs) && bannedUntilMs <= Date.now(); + } catch (error) { + if (error instanceof CliIdentityUnavailableError) throw error; + throw new CliIdentityUnavailableError(); + } +} diff --git a/apps/web/lib/data/public-daily-usage.ts b/apps/web/lib/data/public-daily-usage.ts new file mode 100644 index 00000000..1e4af840 --- /dev/null +++ b/apps/web/lib/data/public-daily-usage.ts @@ -0,0 +1,2 @@ +export const PUBLIC_DAILY_USAGE_FIELDS = + "id,user_id,date,cost_usd,input_tokens,output_tokens,reasoning_output_tokens,cache_creation_tokens,cache_read_tokens,total_tokens,models,model_breakdown,session_count,is_verified,created_at,updated_at" as const; diff --git a/apps/web/lib/feed-enrichment.ts b/apps/web/lib/feed-enrichment.ts index 436a6042..61d7a20a 100644 --- a/apps/web/lib/feed-enrichment.ts +++ b/apps/web/lib/feed-enrichment.ts @@ -6,6 +6,7 @@ import { } from "@/lib/feed-normalization"; import { firstRelation } from "@/lib/utils/first-relation"; import type { CommentPreviewItem, FeedPostRow, Post, UserSummary } from "@/types"; +import { PUBLIC_DAILY_USAGE_FIELDS } from "@/lib/data/public-daily-usage"; type QueryClient = { from: (table: string) => QueryBuilder; @@ -128,7 +129,7 @@ export async function getPendingPosts( const { data } = await db .from("posts") - .select("*, daily_usage:daily_usage!posts_daily_usage_id_fkey!inner(*)") + .select(`*, daily_usage:daily_usage!posts_daily_usage_id_fkey!inner(${PUBLIC_DAILY_USAGE_FIELDS})`) .eq("user_id", userId) .is("description", null) .eq("images", "[]") diff --git a/apps/web/types/index.ts b/apps/web/types/index.ts index 71d65bcf..3756271d 100644 --- a/apps/web/types/index.ts +++ b/apps/web/types/index.ts @@ -41,7 +41,7 @@ export interface DailyUsage { model_breakdown: ModelBreakdownEntry[] | null; session_count: number; is_verified: boolean; - raw_hash: string | null; + raw_hash?: string | null; collector_meta?: UsageCollectorMeta | null; created_at: string; updated_at: string; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5d2370b3..129396c8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -22,6 +22,7 @@ ### Security +- **API writes and privileged RPCs now enforce their server-side security boundaries.** Authenticated PostgREST and Storage clients can no longer bypass validated mutation routes for profiles, usage, posts, social actions, messages, submissions, or uploads. Post deletion uses the same service boundary as other post mutations. Privileged RPCs authorize callers explicitly, private feed and usage data use allow-listed fields and ownership joins, and public RPC work is bounded. CLI requests reject deleted or banned users without treating temporary identity-provider failures as expired sessions. Image uploads verify file signatures; profile, search, submission, and pagination inputs have explicit limits; and search preserves literal underscores in valid usernames. - **Upgraded Next.js to 16.2.6** (from 16.1.6) and `eslint-config-next` to match. Next.js 16.2.6 / 15.5.18 ship fixes for multiple high/moderate/low-severity vulnerabilities and an upstream React issue (Next.js security advisory, 2026-05-07). All 578 web unit tests and the typecheck pass on the new version. ### Fixed diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 2998b2c4..461ce624 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1,5 +1,15 @@ # Architecture & Design Decisions +## Route sensitive mutations through the server service client (2026-08-27) + +**Decision:** Publishable Supabase roles retain only the reads and narrow self-service updates the browser needs. Security-sensitive writes go through authenticated API routes, which validate ownership and input before using the server service client. `SECURITY DEFINER` functions authorize their callers or restrict execution to the service role; public read RPCs expose explicit fields, enforce ownership joins, and bound batch and pagination work. + +**Why:** RLS remains defense in depth, but table grants and Storage policies let authenticated clients bypass route validation, rate limits, identity revalidation, and file-content checks. PostgreSQL also grants new functions to `PUBLIC` by default, so a definer-rights function is unsafe unless its execution grants and internal authorization are explicit. + +**Alternatives considered:** (a) Keep browser mutations and duplicate every route constraint in RLS, grants, and Storage policies. Rejected because the validation would have two owners and application-only checks such as Supabase Admin user state and image signatures cannot be expressed there. (b) Keep broad RPC grants and rely on callers to supply safe identifiers and limits. Rejected because definer-rights functions bypass RLS and public callers can choose arbitrary payloads. (c) Move every read to the service client. Rejected because publishable roles still provide useful RLS enforcement for ordinary reads; only private fields and privileged operations cross the service boundary. + +**Trade-offs:** Route availability now gates sensitive writes, and the service client must preserve each route's ownership predicates. Migration tests lock the grants, function signatures, public field lists, ownership joins, and work limits. Full migration behavior still requires the local Supabase integration suite or equivalent PostgreSQL validation. + ## Negotiate curated Markdown through the Next.js proxy (2026-08-21) **Decision:** The agent independently chose q-value- and specificity-aware content negotiation in the existing Next.js 16 `proxy.ts`. Markdown-preferred requests for supported public informational pages rewrite to a dedicated internal route handler; ordinary HTML, API, asset, mutation, RSC, OAuth callback, and Supabase session behavior remain on their existing paths. Curated Markdown is available for `/`, `/about`, `/contact`, `/privacy`, `/cli`, and `/open`. Unknown document paths can return a recovery-oriented Markdown 404, while an explicit registry of existing static and dynamic page patterns prevents valid HTML-only routes from becoming false 404s. @@ -162,6 +172,8 @@ Pricing the new-logic numbers at gpt-5.5 rates: $228.68 — matches what OpenAI ## `calculate_user_streak` runs as SECURITY DEFINER (2026-04-30) +**Status:** Superseded on 2026-08-27 by "Route sensitive mutations through the server service client." The function remains `SECURITY DEFINER`, but now authorizes access to the requested user's streak and ignores caller-supplied freeze allowances. + **Decision:** Promote `public.calculate_user_streak(uuid, integer)` to `SECURITY DEFINER` with a fixed `search_path = public` and grant `EXECUTE` to both `authenticated` and `anon`. **Why:** The function reads `users.timezone` to compute "today" in the user's local zone. After migration `20260413034500_harden_users_public_columns.sql` replaced the table-wide `GRANT SELECT ON public.users` with a column-level allow-list (which does not include `timezone`, by design — it's not a public profile field), the function began failing with `42501: permission denied for table users` for any caller other than `service_role`/`postgres`. The profile page hid the regression because it uses the service-role client, but the sidebar and the public `/recap/[username]` card silently fell back to 0 / "No active streak". This is the same failure mode that took down `/leaderboard` (see CHANGELOG entry on `20260428120000`). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0698a0b2..b20219d4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -172,11 +172,6 @@ Now that `device_usage` stores per-device data, future work could expose this in The CLI auth init endpoint has rate limiting (5 req/min/IP), but other write endpoints (comments, follows, kudos, upload, usage submit) do not. Consider per-user rate limiting via a shared utility or Supabase Edge Function middleware. Priority: `/api/upload` (file creation), `/api/usage/submit` (data creation), then social actions. -### `calculate_user_streak` Is Callable by `anon` with Any User ID - -`public.calculate_user_streak(UUID, INTEGER)` is `SECURITY DEFINER` and granted `EXECUTE` to both `anon` and `authenticated` (`supabase/migrations/20260430172022_fix_calculate_user_streak_security_definer.sql:85-86`). Because the definer bypasses RLS, anyone can POST to the PostgREST RPC endpoint with an arbitrary `p_user_id` and read that user's streak — including users who set their profile to private. It also works as a presence oracle: a non-zero return means the account has recent usage. - -Every caller in the app is server-side and already uses the service client, so the `anon` and `authenticated` grants appear to be unnecessary surface rather than something the front end depends on. Verify that against `apps/web` call sites, then revoke both grants and keep `service_role`. Long-standing, not introduced by any open PR; found while reviewing #147. ## CSP Hardening (Nonce-Based) diff --git a/supabase/migrations/20260827090000_harden_api_write_privileges.sql b/supabase/migrations/20260827090000_harden_api_write_privileges.sql new file mode 100644 index 00000000..bfb0fe29 --- /dev/null +++ b/supabase/migrations/20260827090000_harden_api_write_privileges.sql @@ -0,0 +1,47 @@ +-- Force security-sensitive writes through the validated, rate-limited API +-- routes. RLS still protects reads and acts as defense in depth, but browser +-- clients must not bypass the route handlers through PostgREST or Storage. + +REVOKE UPDATE ON public.users FROM authenticated; + +REVOKE INSERT, UPDATE ON public.daily_usage FROM authenticated; +REVOKE INSERT, UPDATE ON public.device_usage FROM authenticated; + +REVOKE SELECT ON public.daily_usage FROM anon, authenticated; +GRANT SELECT ( + id, + user_id, + date, + cost_usd, + input_tokens, + output_tokens, + reasoning_output_tokens, + cache_creation_tokens, + cache_read_tokens, + total_tokens, + models, + model_breakdown, + session_count, + is_verified, + created_at, + updated_at +) ON public.daily_usage TO anon, authenticated; + +REVOKE INSERT, UPDATE, DELETE ON public.posts FROM authenticated; + +REVOKE INSERT, DELETE ON public.follows FROM authenticated; +REVOKE INSERT, DELETE ON public.kudos FROM authenticated; +REVOKE INSERT, UPDATE, DELETE ON public.comments FROM authenticated; +REVOKE INSERT, DELETE ON public.comment_reactions FROM authenticated; + +REVOKE INSERT, UPDATE ON public.notifications FROM authenticated; +GRANT UPDATE (read) ON public.notifications TO authenticated; + +REVOKE INSERT ON public.direct_messages FROM authenticated; +REVOKE INSERT ON public.prompt_submissions FROM authenticated; +REVOKE INSERT ON public.company_suggestions FROM authenticated; + +DROP POLICY IF EXISTS "Authenticated users can upload avatars" ON storage.objects; +DROP POLICY IF EXISTS "Authenticated users can upload post images" ON storage.objects; +DROP POLICY IF EXISTS "Authenticated users can upload dm attachments" ON storage.objects; +DROP POLICY IF EXISTS "Users can delete own dm attachments" ON storage.objects; diff --git a/supabase/migrations/20260827090100_harden_privileged_rpcs.sql b/supabase/migrations/20260827090100_harden_privileged_rpcs.sql new file mode 100644 index 00000000..7b44deb7 --- /dev/null +++ b/supabase/migrations/20260827090100_harden_privileged_rpcs.sql @@ -0,0 +1,365 @@ +-- SECURITY DEFINER functions execute with their owner privileges. PostgreSQL +-- grants EXECUTE to PUBLIC by default, so every privileged RPC must either +-- authorize its caller internally or revoke PUBLIC explicitly. + +CREATE OR REPLACE FUNCTION public.increment_streak_freezes( + p_user_id uuid, + p_max integer DEFAULT 7 +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $$ +BEGIN + IF auth.role() IS DISTINCT FROM 'service_role' THEN + RAISE EXCEPTION 'Forbidden' + USING ERRCODE = '42501'; + END IF; + + IF p_max IS NULL OR p_max < 1 OR p_max > 7 THEN + RAISE EXCEPTION 'p_max must be between 1 and 7' + USING ERRCODE = '22023'; + END IF; + + UPDATE public.users + SET streak_freezes = LEAST(streak_freezes + 1, p_max) + WHERE id = p_user_id; +END; +$$; + +REVOKE ALL ON FUNCTION public.increment_streak_freezes(uuid, integer) + FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.increment_streak_freezes(uuid, integer) + TO service_role; + +-- A historical one-argument overload bypasses the authorization below and +-- also makes PostgREST resolution ambiguous with the defaulted second +-- argument. All callers remain compatible with this two-argument function. +DROP FUNCTION IF EXISTS public.calculate_user_streak(uuid); + +CREATE OR REPLACE FUNCTION public.calculate_user_streak( + p_user_id uuid, + p_freeze_days integer DEFAULT 0 +) +RETURNS integer +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = public, pg_temp +AS $$ +DECLARE + streak_count integer := 0; + current_date_check date; + has_usage boolean; + latest_date date; + user_tz text; + user_today date; + grace integer; + v_auth_user_id uuid := auth.uid(); + v_can_view boolean := false; + v_freeze_days integer; +BEGIN + SELECT EXISTS ( + SELECT 1 + FROM public.users u + WHERE u.id = p_user_id + AND ( + auth.role() = 'service_role' + OR u.is_public = true + OR u.id = v_auth_user_id + OR ( + v_auth_user_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM public.follows f + WHERE f.follower_id = v_auth_user_id + AND f.following_id = u.id + ) + ) + ) + ) INTO v_can_view; + + IF NOT v_can_view THEN + RAISE EXCEPTION 'Forbidden' + USING ERRCODE = '42501'; + END IF; + + SELECT + COALESCE(NULLIF(timezone, ''), 'UTC'), + LEAST(GREATEST(COALESCE(streak_freezes, 0), 0), 7) + INTO user_tz, v_freeze_days + FROM public.users + WHERE id = p_user_id; + + IF user_tz IS NULL THEN + user_tz := 'UTC'; + END IF; + + BEGIN + user_today := (now() AT TIME ZONE user_tz)::date; + EXCEPTION WHEN OTHERS THEN + user_today := (now() AT TIME ZONE 'UTC')::date; + END; + + grace := 1 + v_freeze_days; + + SELECT max(date) INTO latest_date + FROM public.daily_usage + WHERE user_id = p_user_id; + + IF latest_date IS NULL OR latest_date < user_today - grace THEN + RETURN 0; + END IF; + + current_date_check := latest_date; + LOOP + SELECT EXISTS ( + SELECT 1 + FROM public.daily_usage + WHERE user_id = p_user_id + AND date = current_date_check + ) INTO has_usage; + + EXIT WHEN NOT has_usage; + streak_count := streak_count + 1; + current_date_check := current_date_check - 1; + END LOOP; + + RETURN streak_count; +END; +$$; + +REVOKE ALL ON FUNCTION public.calculate_user_streak(uuid, integer) + FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.calculate_user_streak(uuid, integer) + TO anon, authenticated, service_role; + +CREATE OR REPLACE FUNCTION public.calculate_streaks_batch(p_user_ids uuid[]) +RETURNS TABLE(user_id uuid, streak integer) +LANGUAGE plpgsql +STABLE +SET search_path = public, pg_temp +AS $$ +DECLARE + v_user_id uuid; +BEGIN + IF p_user_ids IS NULL THEN + RETURN; + END IF; + + IF cardinality(p_user_ids) > 100 THEN + RAISE EXCEPTION 'A maximum of 100 user IDs is allowed' + USING ERRCODE = '22023'; + END IF; + + FOREACH v_user_id IN ARRAY p_user_ids LOOP + user_id := v_user_id; + BEGIN + streak := public.calculate_user_streak(v_user_id, 0); + EXCEPTION + WHEN SQLSTATE '42501' THEN + streak := 0; + END; + RETURN NEXT; + END LOOP; +END; +$$; + +REVOKE ALL ON FUNCTION public.calculate_streaks_batch(uuid[]) + FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.calculate_streaks_batch(uuid[]) + TO anon, authenticated, service_role; + +CREATE OR REPLACE FUNCTION public.get_feed( + p_type text, + p_user_id uuid DEFAULT NULL, + p_limit int DEFAULT 20, + p_cursor_date date DEFAULT NULL, + p_cursor_created_at timestamptz DEFAULT NULL +) +RETURNS TABLE ( + id uuid, + user_id uuid, + daily_usage_id uuid, + title text, + description text, + images jsonb, + created_at timestamptz, + updated_at timestamptz, + "user" jsonb, + daily_usage jsonb, + kudos_count bigint, + comment_count bigint +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = public, pg_temp +AS $$ +DECLARE + v_auth_user_id uuid := auth.uid(); + v_can_view_user boolean := false; + v_limit integer := LEAST(GREATEST(COALESCE(p_limit, 20), 1), 100); +BEGIN + IF p_type IN ('mine', 'following') THEN + IF v_auth_user_id IS NULL OR p_user_id IS NULL OR p_user_id <> v_auth_user_id THEN + RAISE EXCEPTION 'Unauthorized' + USING ERRCODE = '42501'; + END IF; + ELSIF p_type = 'user' THEN + IF p_user_id IS NULL THEN + RAISE EXCEPTION 'user_id is required for user feed type' + USING ERRCODE = '22023'; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM public.users u + WHERE u.id = p_user_id + AND ( + u.is_public = true + OR u.id = v_auth_user_id + OR ( + v_auth_user_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM public.follows f + WHERE f.follower_id = v_auth_user_id + AND f.following_id = u.id + ) + ) + ) + ) INTO v_can_view_user; + + IF NOT v_can_view_user THEN + RAISE EXCEPTION 'Forbidden' + USING ERRCODE = '42501'; + END IF; + ELSIF p_type <> 'global' THEN + RAISE EXCEPTION 'Invalid feed type: %', p_type + USING ERRCODE = '22023'; + END IF; + + RETURN QUERY + SELECT + p.id, + p.user_id, + p.daily_usage_id, + p.title, + p.description, + p.images, + p.created_at, + p.updated_at, + jsonb_build_object( + 'id', u.id, + 'username', u.username, + 'display_name', u.display_name, + 'bio', u.bio, + 'avatar_url', u.avatar_url, + 'country', u.country, + 'region', u.region, + 'link', u.link, + 'github_username', u.github_username, + 'team_url', u.team_url, + 'team_favicon_url', u.team_favicon_url, + 'is_public', u.is_public + ) AS "user", + jsonb_build_object( + 'id', d.id, + 'user_id', d.user_id, + 'date', d.date, + 'cost_usd', d.cost_usd, + 'input_tokens', d.input_tokens, + 'output_tokens', d.output_tokens, + 'reasoning_output_tokens', d.reasoning_output_tokens, + 'cache_creation_tokens', d.cache_creation_tokens, + 'cache_read_tokens', d.cache_read_tokens, + 'total_tokens', d.total_tokens, + 'models', d.models, + 'model_breakdown', d.model_breakdown, + 'session_count', d.session_count, + 'is_verified', d.is_verified, + 'created_at', d.created_at, + 'updated_at', d.updated_at + ) AS daily_usage, + (SELECT count(*) FROM public.kudos k WHERE k.post_id = p.id) AS kudos_count, + (SELECT count(*) FROM public.comments c WHERE c.post_id = p.id) AS comment_count + FROM public.posts p + JOIN public.users u ON u.id = p.user_id + JOIN public.daily_usage d + ON d.id = p.daily_usage_id + AND d.user_id = p.user_id + WHERE + CASE p_type + WHEN 'global' THEN u.is_public = true + WHEN 'mine' THEN p.user_id = p_user_id + WHEN 'user' THEN p.user_id = p_user_id + WHEN 'following' THEN + p.user_id = p_user_id + OR EXISTS ( + SELECT 1 + FROM public.follows f + WHERE f.follower_id = p_user_id + AND f.following_id = p.user_id + ) + ELSE false + END + AND CASE + WHEN p_cursor_date IS NULL AND p_cursor_created_at IS NULL THEN true + WHEN p_cursor_date IS NOT NULL THEN + d.date < p_cursor_date + OR (d.date = p_cursor_date AND p.created_at < p_cursor_created_at) + ELSE p.created_at < p_cursor_created_at + END + ORDER BY d.date DESC, p.created_at DESC + LIMIT v_limit; +END; +$$; + +REVOKE ALL ON FUNCTION public.get_feed(text, uuid, int, date, timestamptz) + FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.get_feed(text, uuid, int, date, timestamptz) + TO anon, authenticated, service_role; + +-- The application no longer calls the legacy following-feed RPC. Its latest +-- definition returns to_jsonb(users.*), so leave it available only to trusted +-- server code until it can be removed. +REVOKE ALL ON FUNCTION public.get_following_feed(uuid, int, timestamptz) + FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.get_following_feed(uuid, int, timestamptz) + TO service_role; + +REVOKE ALL ON FUNCTION public.get_direct_message_threads(int) + FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.get_direct_message_threads(int) + TO authenticated, service_role; + +REVOKE ALL ON FUNCTION public.admin_cumulative_spend() + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.admin_top_users(int) + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.admin_activation_funnel() + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.admin_growth_metrics() + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.admin_cohort_retention() + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.admin_revenue_concentration() + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.admin_time_to_first_sync() + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.admin_model_usage_by_day() + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.admin_model_share_by_day() + FROM PUBLIC, anon, authenticated; + +GRANT EXECUTE ON FUNCTION public.admin_cumulative_spend() TO service_role; +GRANT EXECUTE ON FUNCTION public.admin_top_users(int) TO service_role; +GRANT EXECUTE ON FUNCTION public.admin_activation_funnel() TO service_role; +GRANT EXECUTE ON FUNCTION public.admin_growth_metrics() TO service_role; +GRANT EXECUTE ON FUNCTION public.admin_cohort_retention() TO service_role; +GRANT EXECUTE ON FUNCTION public.admin_revenue_concentration() TO service_role; +GRANT EXECUTE ON FUNCTION public.admin_time_to_first_sync() TO service_role; +GRANT EXECUTE ON FUNCTION public.admin_model_usage_by_day() TO service_role; +GRANT EXECUTE ON FUNCTION public.admin_model_share_by_day() TO service_role;