From d69feafdbb9322ece7ccd96922576ecd6f0e60b8 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 13:51:09 -0500 Subject: [PATCH 01/12] feat: add RECOUP_ORG_ID constant for admin organization access Add the Recoup admin organization UUID constant to support the accountId override feature. API keys from this organization have universal access and can specify any accountId when creating chats. Co-Authored-By: Claude Opus 4.5 --- lib/const.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/const.ts b/lib/const.ts index 8717e4a5..bd1e73a2 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -22,3 +22,9 @@ export const OUTBOUND_EMAIL_DOMAIN = "@recoupable.com"; export const RECOUP_FROM_EMAIL = `Agent by Recoup `; export const SUPABASE_STORAGE_BUCKET = "user-files"; + +/** + * UUID of the Recoup admin organization. + * API keys from this org have universal access and can specify any accountId. + */ +export const RECOUP_ORG_ID = "04e3aba9-c130-4fb8-8b92-34e95d43e66b"; From e3f4ae1c5afabdcc178a08c27eed8ea65f6545fc Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 13:56:42 -0500 Subject: [PATCH 02/12] feat: add getApiKeyDetails function for org context resolution Adds a new authentication utility that extracts both accountId and orgId from an API key. This is needed for the accountId override feature where org API keys can specify a target accountId. - Returns accountId from the API key's account field - Returns orgId if the account is an organization (exists in account_organization_ids) - Returns null orgId for personal API keys - Includes 7 unit tests covering all scenarios Co-Authored-By: Claude Opus 4.5 --- lib/auth/__tests__/getApiKeyDetails.test.ts | 144 ++++++++++++++++++++ lib/auth/getApiKeyDetails.ts | 58 ++++++++ 2 files changed, 202 insertions(+) create mode 100644 lib/auth/__tests__/getApiKeyDetails.test.ts create mode 100644 lib/auth/getApiKeyDetails.ts diff --git a/lib/auth/__tests__/getApiKeyDetails.test.ts b/lib/auth/__tests__/getApiKeyDetails.test.ts new file mode 100644 index 00000000..b404725d --- /dev/null +++ b/lib/auth/__tests__/getApiKeyDetails.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getApiKeyDetails } from "../getApiKeyDetails"; + +// Mock dependencies +vi.mock("@/lib/keys/hashApiKey", () => ({ + hashApiKey: vi.fn((key: string) => `hashed_${key}`), +})); + +vi.mock("@/lib/const", () => ({ + PRIVY_PROJECT_SECRET: "test_secret", +})); + +vi.mock("@/lib/supabase/account_api_keys/selectAccountApiKeys", () => ({ + selectAccountApiKeys: vi.fn(), +})); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + maybeSingle: vi.fn(), + })), + })), + })), + }, +})); + +import { selectAccountApiKeys } from "@/lib/supabase/account_api_keys/selectAccountApiKeys"; +import supabase from "@/lib/supabase/serverClient"; + +describe("getApiKeyDetails", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("valid API keys", () => { + it("returns accountId and null orgId for personal API key", async () => { + const personalAccountId = "personal-account-123"; + + vi.mocked(selectAccountApiKeys).mockResolvedValue([ + { + id: "key-1", + account: personalAccountId, + name: "My API Key", + key_hash: "hashed_test_api_key", + created_at: "2024-01-01T00:00:00Z", + last_used: null, + }, + ]); + + // Mock supabase to return no organization (this is a personal key) + const mockMaybeSingle = vi.fn().mockResolvedValue({ data: null, error: null }); + const mockEq = vi.fn().mockReturnValue({ maybeSingle: mockMaybeSingle }); + const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }); + vi.mocked(supabase.from).mockReturnValue({ select: mockSelect } as any); + + const result = await getApiKeyDetails("test_api_key"); + + expect(result).toEqual({ + accountId: personalAccountId, + orgId: null, + }); + }); + + it("returns accountId and orgId for organization API key", async () => { + const orgId = "org-123"; + + vi.mocked(selectAccountApiKeys).mockResolvedValue([ + { + id: "key-1", + account: orgId, + name: "Org API Key", + key_hash: "hashed_org_api_key", + created_at: "2024-01-01T00:00:00Z", + last_used: null, + }, + ]); + + // Mock supabase to return that this account IS an organization + const mockMaybeSingle = vi.fn().mockResolvedValue({ + data: { organization_id: orgId }, + error: null, + }); + const mockEq = vi.fn().mockReturnValue({ maybeSingle: mockMaybeSingle }); + const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }); + vi.mocked(supabase.from).mockReturnValue({ select: mockSelect } as any); + + const result = await getApiKeyDetails("org_api_key"); + + expect(result).toEqual({ + accountId: orgId, + orgId: orgId, + }); + }); + }); + + describe("invalid inputs", () => { + it("returns null for empty API key", async () => { + const result = await getApiKeyDetails(""); + + expect(result).toBeNull(); + }); + + it("returns null for undefined API key", async () => { + const result = await getApiKeyDetails(undefined as unknown as string); + + expect(result).toBeNull(); + }); + + it("returns null when API key not found in database", async () => { + vi.mocked(selectAccountApiKeys).mockResolvedValue([]); + + const result = await getApiKeyDetails("invalid_api_key"); + + expect(result).toBeNull(); + }); + + it("returns null when selectAccountApiKeys returns null (error)", async () => { + vi.mocked(selectAccountApiKeys).mockResolvedValue(null); + + const result = await getApiKeyDetails("test_api_key"); + + expect(result).toBeNull(); + }); + + it("returns null when API key record has null account", async () => { + vi.mocked(selectAccountApiKeys).mockResolvedValue([ + { + id: "key-1", + account: null, + name: "Broken Key", + key_hash: "hashed_test_key", + created_at: "2024-01-01T00:00:00Z", + last_used: null, + }, + ]); + + const result = await getApiKeyDetails("test_key"); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/lib/auth/getApiKeyDetails.ts b/lib/auth/getApiKeyDetails.ts new file mode 100644 index 00000000..44f0bd9f --- /dev/null +++ b/lib/auth/getApiKeyDetails.ts @@ -0,0 +1,58 @@ +import { hashApiKey } from "@/lib/keys/hashApiKey"; +import { PRIVY_PROJECT_SECRET } from "@/lib/const"; +import { selectAccountApiKeys } from "@/lib/supabase/account_api_keys/selectAccountApiKeys"; +import supabase from "@/lib/supabase/serverClient"; + +export interface ApiKeyDetails { + accountId: string; + orgId: string | null; +} + +/** + * Retrieves details for an API key including the account ID and organization context. + * + * For organization API keys, orgId will be set to the organization's account ID. + * For personal API keys, orgId will be null. + * + * @param apiKey - The raw API key string + * @returns ApiKeyDetails object with accountId and orgId, or null if key is invalid + */ +export async function getApiKeyDetails( + apiKey: string, +): Promise { + if (!apiKey) { + return null; + } + + try { + const keyHash = hashApiKey(apiKey, PRIVY_PROJECT_SECRET); + const apiKeys = await selectAccountApiKeys({ keyHash }); + + if (apiKeys === null || apiKeys.length === 0) { + return null; + } + + const accountId = apiKeys[0]?.account ?? null; + + if (!accountId) { + return null; + } + + // Check if this account is an organization by looking it up in account_organization_ids + const { data: orgRecord } = await supabase + .from("account_organization_ids") + .select("organization_id") + .eq("organization_id", accountId) + .maybeSingle(); + + const orgId = orgRecord?.organization_id ?? null; + + return { + accountId, + orgId, + }; + } catch (error) { + console.error("[ERROR] getApiKeyDetails:", error); + return null; + } +} From 80164dbd1109f33ecc243aa9e55eff8094cdd2e3 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 13:59:48 -0500 Subject: [PATCH 03/12] feat: add canAccessAccount validation function for org access control Co-Authored-By: Claude Opus 4.5 --- lib/auth/__tests__/canAccessAccount.test.ts | 145 ++++++++++++++++++++ lib/auth/canAccessAccount.ts | 48 +++++++ 2 files changed, 193 insertions(+) create mode 100644 lib/auth/__tests__/canAccessAccount.test.ts create mode 100644 lib/auth/canAccessAccount.ts diff --git a/lib/auth/__tests__/canAccessAccount.test.ts b/lib/auth/__tests__/canAccessAccount.test.ts new file mode 100644 index 00000000..ff7b9662 --- /dev/null +++ b/lib/auth/__tests__/canAccessAccount.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { canAccessAccount } from "../canAccessAccount"; + +// Mock RECOUP_ORG_ID constant +vi.mock("@/lib/const", () => ({ + RECOUP_ORG_ID: "recoup-admin-org-id", +})); + +// Mock supabase client +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + eq: vi.fn(() => ({ + maybeSingle: vi.fn(), + })), + })), + })), + })), + }, +})); + +import supabase from "@/lib/supabase/serverClient"; + +describe("canAccessAccount", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Recoup admin organization", () => { + it("returns true when orgId is RECOUP_ORG_ID (universal access)", async () => { + const result = await canAccessAccount({ + orgId: "recoup-admin-org-id", + targetAccountId: "any-account-123", + }); + + expect(result).toBe(true); + // Should not query database for universal access + expect(supabase.from).not.toHaveBeenCalled(); + }); + }); + + describe("organization member access", () => { + it("returns true when target account is a member of the organization", async () => { + const orgId = "org-456"; + const targetAccountId = "member-account-789"; + + // Mock supabase to return that the account IS a member + const mockMaybeSingle = vi.fn().mockResolvedValue({ + data: { account_id: targetAccountId, organization_id: orgId }, + error: null, + }); + const mockEqOrg = vi.fn().mockReturnValue({ maybeSingle: mockMaybeSingle }); + const mockEqAccount = vi.fn().mockReturnValue({ eq: mockEqOrg }); + const mockSelect = vi.fn().mockReturnValue({ eq: mockEqAccount }); + vi.mocked(supabase.from).mockReturnValue({ select: mockSelect } as any); + + const result = await canAccessAccount({ + orgId, + targetAccountId, + }); + + expect(result).toBe(true); + expect(supabase.from).toHaveBeenCalledWith("account_organization_ids"); + }); + + it("returns false when target account is NOT a member of the organization", async () => { + const orgId = "org-456"; + const targetAccountId = "non-member-account-999"; + + // Mock supabase to return no match (not a member) + const mockMaybeSingle = vi.fn().mockResolvedValue({ + data: null, + error: null, + }); + const mockEqOrg = vi.fn().mockReturnValue({ maybeSingle: mockMaybeSingle }); + const mockEqAccount = vi.fn().mockReturnValue({ eq: mockEqOrg }); + const mockSelect = vi.fn().mockReturnValue({ eq: mockEqAccount }); + vi.mocked(supabase.from).mockReturnValue({ select: mockSelect } as any); + + const result = await canAccessAccount({ + orgId, + targetAccountId, + }); + + expect(result).toBe(false); + }); + }); + + describe("invalid inputs", () => { + it("returns false when orgId is null", async () => { + const result = await canAccessAccount({ + orgId: null, + targetAccountId: "account-123", + }); + + expect(result).toBe(false); + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it("returns false when targetAccountId is empty", async () => { + const result = await canAccessAccount({ + orgId: "org-456", + targetAccountId: "", + }); + + expect(result).toBe(false); + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it("returns false when both orgId and targetAccountId are invalid", async () => { + const result = await canAccessAccount({ + orgId: null, + targetAccountId: "", + }); + + expect(result).toBe(false); + }); + }); + + describe("error handling", () => { + it("returns false when database query throws an error", async () => { + const orgId = "org-456"; + const targetAccountId = "account-123"; + + // Mock supabase to return an error + const mockMaybeSingle = vi.fn().mockResolvedValue({ + data: null, + error: { message: "Database connection failed" }, + }); + const mockEqOrg = vi.fn().mockReturnValue({ maybeSingle: mockMaybeSingle }); + const mockEqAccount = vi.fn().mockReturnValue({ eq: mockEqOrg }); + const mockSelect = vi.fn().mockReturnValue({ eq: mockEqAccount }); + vi.mocked(supabase.from).mockReturnValue({ select: mockSelect } as any); + + const result = await canAccessAccount({ + orgId, + targetAccountId, + }); + + expect(result).toBe(false); + }); + }); +}); diff --git a/lib/auth/canAccessAccount.ts b/lib/auth/canAccessAccount.ts new file mode 100644 index 00000000..04b0f48b --- /dev/null +++ b/lib/auth/canAccessAccount.ts @@ -0,0 +1,48 @@ +import { RECOUP_ORG_ID } from "@/lib/const"; +import supabase from "@/lib/supabase/serverClient"; + +export interface CanAccessAccountParams { + orgId: string | null; + targetAccountId: string; +} + +/** + * Validates if an organization can access a target account. + * + * Access rules: + * - If orgId is RECOUP_ORG_ID, always grants access (universal admin access) + * - Otherwise, checks if targetAccountId is a member of the organization + * + * @param params - The validation parameters + * @param params.orgId - The organization ID from the API key + * @param params.targetAccountId - The account ID to access + * @returns true if access is allowed, false otherwise + */ +export async function canAccessAccount( + params: CanAccessAccountParams, +): Promise { + const { orgId, targetAccountId } = params; + + if (!orgId || !targetAccountId) { + return false; + } + + // Universal access for Recoup admin organization + if (orgId === RECOUP_ORG_ID) { + return true; + } + + // Check if target account is a member of the organization + const { data, error } = await supabase + .from("account_organization_ids") + .select("account_id") + .eq("account_id", targetAccountId) + .eq("organization_id", orgId) + .maybeSingle(); + + if (error) { + return false; + } + + return data !== null; +} From a30a69ff51d116c3fdc32d8b806d513976becd23 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 14:02:52 -0500 Subject: [PATCH 04/12] docs: add Supabase database operations architecture to CLAUDE.md Document that all Supabase calls must be in lib/supabase/[table_name]/[function].ts - Add directory structure example - Add naming conventions (select, insert, update, delete, get) - Add code pattern template Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 20dc37f2..30e89a15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,69 @@ pnpm format:check # Check formatting - `lib/trigger/` - Trigger.dev task triggers - `lib/x402/` - Payment middleware utilities +## Supabase Database Operations + +All Supabase database calls **must** be in `lib/supabase/[table_name]/[function].ts`. Never put Supabase queries directly in `lib/auth/`, `lib/chats/`, or other domain folders. + +### Directory Structure + +``` +lib/supabase/ +├── serverClient.ts # Supabase client instance +├── accounts/ +│ ├── selectAccounts.ts +│ ├── insertAccount.ts +│ └── updateAccount.ts +├── account_api_keys/ +│ ├── selectAccountApiKeys.ts +│ ├── insertApiKey.ts +│ └── deleteApiKey.ts +├── account_organization_ids/ +│ ├── getAccountOrganizations.ts +│ └── addAccountToOrganization.ts +└── [table_name]/ + └── [action][TableName].ts +``` + +### Naming Conventions + +- `select[TableName].ts` - Basic SELECT queries +- `insert[TableName].ts` - INSERT queries +- `update[TableName].ts` - UPDATE queries +- `delete[TableName].ts` - DELETE queries +- `get[Descriptive].ts` - Complex queries with joins + +### Pattern + +```typescript +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Select rows from table_name with optional filters. + */ +export async function selectTableName({ + filter, +}: { + filter?: string; +} = {}): Promise[] | null> { + let query = supabase.from("table_name").select("*"); + + if (filter) { + query = query.eq("column", filter); + } + + const { data, error } = await query; + + if (error) { + console.error("Error fetching table_name:", error); + return null; + } + + return data || []; +} +``` + ## Code Principles - **SRP (Single Responsibility Principle)**: One exported function per file. Each file should do one thing well. From da3724f1a89ba84a084983429a3ecc8dd7625d3a Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 14:03:22 -0500 Subject: [PATCH 05/12] feat: add optional accountId to createChat validation schema Add accountId field to POST /api/chats validation schema to enable account override for organization API keys. Includes 9 unit tests for the validation logic. Co-Authored-By: Claude Opus 4.5 --- .../__tests__/validateCreateChatBody.test.ts | 104 ++++++++++++++++++ lib/chats/validateCreateChatBody.ts | 1 + 2 files changed, 105 insertions(+) create mode 100644 lib/chats/__tests__/validateCreateChatBody.test.ts diff --git a/lib/chats/__tests__/validateCreateChatBody.test.ts b/lib/chats/__tests__/validateCreateChatBody.test.ts new file mode 100644 index 00000000..851171d8 --- /dev/null +++ b/lib/chats/__tests__/validateCreateChatBody.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; +import { NextResponse } from "next/server"; +import { + validateCreateChatBody, + createChatBodySchema, +} from "../validateCreateChatBody"; + +describe("validateCreateChatBody", () => { + describe("artistId validation", () => { + it("accepts valid UUID for artistId", () => { + const result = validateCreateChatBody({ + artistId: "123e4567-e89b-12d3-a456-426614174000", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).artistId).toBe( + "123e4567-e89b-12d3-a456-426614174000", + ); + }); + + it("rejects invalid UUID for artistId", () => { + const result = validateCreateChatBody({ + artistId: "invalid-uuid", + }); + + expect(result).toBeInstanceOf(NextResponse); + }); + + it("accepts missing artistId (optional)", () => { + const result = validateCreateChatBody({}); + + expect(result).not.toBeInstanceOf(NextResponse); + }); + }); + + describe("chatId validation", () => { + it("accepts valid UUID for chatId", () => { + const result = validateCreateChatBody({ + chatId: "123e4567-e89b-12d3-a456-426614174000", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).chatId).toBe( + "123e4567-e89b-12d3-a456-426614174000", + ); + }); + + it("rejects invalid UUID for chatId", () => { + const result = validateCreateChatBody({ + chatId: "invalid-uuid", + }); + + expect(result).toBeInstanceOf(NextResponse); + }); + }); + + describe("accountId validation", () => { + it("accepts valid UUID for accountId", () => { + const result = validateCreateChatBody({ + accountId: "123e4567-e89b-12d3-a456-426614174000", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe( + "123e4567-e89b-12d3-a456-426614174000", + ); + }); + + it("rejects invalid UUID for accountId", () => { + const result = validateCreateChatBody({ + accountId: "invalid-uuid", + }); + + expect(result).toBeInstanceOf(NextResponse); + }); + + it("accepts missing accountId (optional)", () => { + const result = validateCreateChatBody({ + artistId: "123e4567-e89b-12d3-a456-426614174000", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBeUndefined(); + }); + }); + + describe("schema type inference", () => { + it("schema should include accountId as optional UUID field", () => { + const validBody = { + artistId: "123e4567-e89b-12d3-a456-426614174000", + chatId: "123e4567-e89b-12d3-a456-426614174001", + accountId: "123e4567-e89b-12d3-a456-426614174002", + }; + + const result = createChatBodySchema.safeParse(validBody); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.accountId).toBe( + "123e4567-e89b-12d3-a456-426614174002", + ); + } + }); + }); +}); diff --git a/lib/chats/validateCreateChatBody.ts b/lib/chats/validateCreateChatBody.ts index 98d8ab4f..9e4e3f3a 100644 --- a/lib/chats/validateCreateChatBody.ts +++ b/lib/chats/validateCreateChatBody.ts @@ -5,6 +5,7 @@ import { z } from "zod"; export const createChatBodySchema = z.object({ artistId: z.string().uuid("artistId must be a valid UUID").optional(), chatId: z.string().uuid("chatId must be a valid UUID").optional(), + accountId: z.string().uuid("accountId must be a valid UUID").optional(), }); export type CreateChatBody = z.infer; From 45db35a7b48907e5375f14b2470450f33af6bdf1 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 14:10:20 -0500 Subject: [PATCH 06/12] feat: add accountId override support to createChatHandler Allow org API keys to create chat rooms for accounts within their org. - When accountId provided, validates access via getApiKeyDetails + canAccessAccount - Returns 403 if access denied, 500 if API key validation fails - Recoup admin org has universal access to all accounts - Includes 6 unit tests Co-Authored-By: Claude Opus 4.5 --- lib/chats/__tests__/createChatHandler.test.ts | 226 ++++++++++++++++++ lib/chats/createChatHandler.ts | 60 ++++- 2 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 lib/chats/__tests__/createChatHandler.test.ts diff --git a/lib/chats/__tests__/createChatHandler.test.ts b/lib/chats/__tests__/createChatHandler.test.ts new file mode 100644 index 00000000..a1860489 --- /dev/null +++ b/lib/chats/__tests__/createChatHandler.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { createChatHandler } from "../createChatHandler"; + +// Mock dependencies +vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ + getApiKeyAccountId: vi.fn(), +})); + +vi.mock("@/lib/auth/getApiKeyDetails", () => ({ + getApiKeyDetails: vi.fn(), +})); + +vi.mock("@/lib/auth/canAccessAccount", () => ({ + canAccessAccount: vi.fn(), +})); + +vi.mock("@/lib/supabase/rooms/insertRoom", () => ({ + insertRoom: vi.fn(), +})); + +vi.mock("@/lib/uuid/generateUUID", () => ({ + generateUUID: vi.fn(() => "generated-uuid-123"), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/networking/safeParseJson", () => ({ + safeParseJson: vi.fn(), +})); + +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { getApiKeyDetails } from "@/lib/auth/getApiKeyDetails"; +import { canAccessAccount } from "@/lib/auth/canAccessAccount"; +import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; + +function createMockRequest(apiKey = "test-api-key"): NextRequest { + return { + headers: { + get: (name: string) => (name === "x-api-key" ? apiKey : null), + }, + } as unknown as NextRequest; +} + +describe("createChatHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("without accountId override", () => { + it("uses API key's accountId when no accountId in body", async () => { + const apiKeyAccountId = "api-key-account-123"; + const artistId = "123e4567-e89b-12d3-a456-426614174000"; + + vi.mocked(getApiKeyAccountId).mockResolvedValue(apiKeyAccountId); + vi.mocked(safeParseJson).mockResolvedValue({ artistId }); + vi.mocked(insertRoom).mockResolvedValue({ + id: "generated-uuid-123", + account_id: apiKeyAccountId, + artist_id: artistId, + topic: null, + }); + + const request = createMockRequest(); + const response = await createChatHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.status).toBe("success"); + expect(insertRoom).toHaveBeenCalledWith({ + id: "generated-uuid-123", + account_id: apiKeyAccountId, + artist_id: artistId, + topic: null, + }); + }); + }); + + describe("with accountId override", () => { + it("uses body accountId when org has access (Recoup admin)", async () => { + const apiKeyAccountId = "recoup-org-account"; + const targetAccountId = "123e4567-e89b-12d3-a456-426614174001"; + const artistId = "123e4567-e89b-12d3-a456-426614174000"; + + vi.mocked(getApiKeyAccountId).mockResolvedValue(apiKeyAccountId); + vi.mocked(safeParseJson).mockResolvedValue({ + artistId, + accountId: targetAccountId, + }); + vi.mocked(getApiKeyDetails).mockResolvedValue({ + accountId: apiKeyAccountId, + orgId: "recoup-admin-org-id", + }); + vi.mocked(canAccessAccount).mockResolvedValue(true); + vi.mocked(insertRoom).mockResolvedValue({ + id: "generated-uuid-123", + account_id: targetAccountId, + artist_id: artistId, + topic: null, + }); + + const request = createMockRequest(); + const response = await createChatHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.status).toBe("success"); + expect(canAccessAccount).toHaveBeenCalledWith({ + orgId: "recoup-admin-org-id", + targetAccountId, + }); + expect(insertRoom).toHaveBeenCalledWith({ + id: "generated-uuid-123", + account_id: targetAccountId, + artist_id: artistId, + topic: null, + }); + }); + + it("uses body accountId when org has access (org member)", async () => { + const apiKeyAccountId = "org-account"; + const targetAccountId = "123e4567-e89b-12d3-a456-426614174001"; + const orgId = "regular-org-id"; + + vi.mocked(getApiKeyAccountId).mockResolvedValue(apiKeyAccountId); + vi.mocked(safeParseJson).mockResolvedValue({ + accountId: targetAccountId, + }); + vi.mocked(getApiKeyDetails).mockResolvedValue({ + accountId: apiKeyAccountId, + orgId, + }); + vi.mocked(canAccessAccount).mockResolvedValue(true); + vi.mocked(insertRoom).mockResolvedValue({ + id: "generated-uuid-123", + account_id: targetAccountId, + artist_id: null, + topic: null, + }); + + const request = createMockRequest(); + const response = await createChatHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.status).toBe("success"); + expect(insertRoom).toHaveBeenCalledWith({ + id: "generated-uuid-123", + account_id: targetAccountId, + artist_id: null, + topic: null, + }); + }); + + it("returns 403 when org lacks access to target account", async () => { + const apiKeyAccountId = "org-account"; + const targetAccountId = "123e4567-e89b-12d3-a456-426614174001"; + const orgId = "regular-org-id"; + + vi.mocked(getApiKeyAccountId).mockResolvedValue(apiKeyAccountId); + vi.mocked(safeParseJson).mockResolvedValue({ + accountId: targetAccountId, + }); + vi.mocked(getApiKeyDetails).mockResolvedValue({ + accountId: apiKeyAccountId, + orgId, + }); + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const request = createMockRequest(); + const response = await createChatHandler(request); + const json = await response.json(); + + expect(response.status).toBe(403); + expect(json.status).toBe("error"); + expect(json.message).toBe("Access denied to specified accountId"); + expect(insertRoom).not.toHaveBeenCalled(); + }); + + it("returns 403 when personal key tries to use accountId override", async () => { + const apiKeyAccountId = "personal-account-123"; + const targetAccountId = "123e4567-e89b-12d3-a456-426614174001"; + + vi.mocked(getApiKeyAccountId).mockResolvedValue(apiKeyAccountId); + vi.mocked(safeParseJson).mockResolvedValue({ + accountId: targetAccountId, + }); + vi.mocked(getApiKeyDetails).mockResolvedValue({ + accountId: apiKeyAccountId, + orgId: null, // Personal key has no org + }); + + const request = createMockRequest(); + const response = await createChatHandler(request); + const json = await response.json(); + + expect(response.status).toBe(403); + expect(json.status).toBe("error"); + expect(json.message).toBe("Access denied to specified accountId"); + expect(insertRoom).not.toHaveBeenCalled(); + }); + + it("returns 500 when getApiKeyDetails fails", async () => { + const apiKeyAccountId = "org-account"; + const targetAccountId = "123e4567-e89b-12d3-a456-426614174001"; + + vi.mocked(getApiKeyAccountId).mockResolvedValue(apiKeyAccountId); + vi.mocked(safeParseJson).mockResolvedValue({ + accountId: targetAccountId, + }); + vi.mocked(getApiKeyDetails).mockResolvedValue(null); + + const request = createMockRequest(); + const response = await createChatHandler(request); + const json = await response.json(); + + expect(response.status).toBe(500); + expect(json.status).toBe("error"); + expect(json.message).toBe("Failed to validate API key"); + expect(insertRoom).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/lib/chats/createChatHandler.ts b/lib/chats/createChatHandler.ts index ad1c592f..cc473def 100644 --- a/lib/chats/createChatHandler.ts +++ b/lib/chats/createChatHandler.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { getApiKeyDetails } from "@/lib/auth/getApiKeyDetails"; +import { canAccessAccount } from "@/lib/auth/canAccessAccount"; import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; import { generateUUID } from "@/lib/uuid/generateUUID"; import { validateCreateChatBody } from "@/lib/chats/validateCreateChatBody"; @@ -10,7 +12,8 @@ import { safeParseJson } from "@/lib/networking/safeParseJson"; * Handler for creating a new chat room. * * Requires authentication via x-api-key header. - * The account ID is inferred from the API key. + * The account ID is inferred from the API key, unless an accountId is provided + * in the request body by an organization API key with access to that account. * * @param request - The NextRequest object * @returns A NextResponse with the created chat or an error @@ -22,7 +25,7 @@ export async function createChatHandler(request: NextRequest): Promise Date: Wed, 14 Jan 2026 14:13:03 -0500 Subject: [PATCH 07/12] docs: strengthen Supabase architecture rule in CLAUDE.md - Add CRITICAL marker: NEVER import serverClient outside lib/supabase/ - Add 3-step process for database access in domain code - Add WRONG vs CORRECT code examples - Make rule more prominent and actionable Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 30e89a15..ea5b2671 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,28 @@ pnpm format:check # Check formatting ## Supabase Database Operations -All Supabase database calls **must** be in `lib/supabase/[table_name]/[function].ts`. Never put Supabase queries directly in `lib/auth/`, `lib/chats/`, or other domain folders. +**CRITICAL: NEVER import `@/lib/supabase/serverClient` outside of `lib/supabase/` directory.** + +All Supabase database calls **must** be in `lib/supabase/[table_name]/[function].ts`. + +If you need database access in `lib/auth/`, `lib/chats/`, or any other domain folder: +1. **First** check if a function already exists in `lib/supabase/[table_name]/` +2. If not, **create** a new function in `lib/supabase/[table_name]/` first +3. **Then** import and use that function in your domain code + +❌ **WRONG** - Direct Supabase call in domain code: +```typescript +// lib/auth/someFunction.ts +import supabase from "@/lib/supabase/serverClient"; // NEVER DO THIS +const { data } = await supabase.from("accounts").select("*"); +``` + +✅ **CORRECT** - Import from supabase lib: +```typescript +// lib/auth/someFunction.ts +import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; +const accounts = await selectAccounts(); +``` ### Directory Structure From 40e5b1e70b7904b7752f24139ef55d45845b9135 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 14:17:34 -0500 Subject: [PATCH 08/12] refactor: use getAccountOrganizations in canAccessAccount Replace direct Supabase query with existing getAccountOrganizations() lib function, following the architecture pattern documented in CLAUDE.md. Co-Authored-By: Claude Opus 4.5 --- lib/auth/__tests__/canAccessAccount.test.ts | 86 +++++++-------------- lib/auth/canAccessAccount.ts | 18 ++--- 2 files changed, 32 insertions(+), 72 deletions(-) diff --git a/lib/auth/__tests__/canAccessAccount.test.ts b/lib/auth/__tests__/canAccessAccount.test.ts index ff7b9662..54f5a231 100644 --- a/lib/auth/__tests__/canAccessAccount.test.ts +++ b/lib/auth/__tests__/canAccessAccount.test.ts @@ -6,22 +6,12 @@ vi.mock("@/lib/const", () => ({ RECOUP_ORG_ID: "recoup-admin-org-id", })); -// Mock supabase client -vi.mock("@/lib/supabase/serverClient", () => ({ - default: { - from: vi.fn(() => ({ - select: vi.fn(() => ({ - eq: vi.fn(() => ({ - eq: vi.fn(() => ({ - maybeSingle: vi.fn(), - })), - })), - })), - })), - }, +// Mock getAccountOrganizations supabase lib +vi.mock("@/lib/supabase/account_organization_ids/getAccountOrganizations", () => ({ + getAccountOrganizations: vi.fn(), })); -import supabase from "@/lib/supabase/serverClient"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; describe("canAccessAccount", () => { beforeEach(() => { @@ -37,7 +27,7 @@ describe("canAccessAccount", () => { expect(result).toBe(true); // Should not query database for universal access - expect(supabase.from).not.toHaveBeenCalled(); + expect(getAccountOrganizations).not.toHaveBeenCalled(); }); }); @@ -46,15 +36,15 @@ describe("canAccessAccount", () => { const orgId = "org-456"; const targetAccountId = "member-account-789"; - // Mock supabase to return that the account IS a member - const mockMaybeSingle = vi.fn().mockResolvedValue({ - data: { account_id: targetAccountId, organization_id: orgId }, - error: null, - }); - const mockEqOrg = vi.fn().mockReturnValue({ maybeSingle: mockMaybeSingle }); - const mockEqAccount = vi.fn().mockReturnValue({ eq: mockEqOrg }); - const mockSelect = vi.fn().mockReturnValue({ eq: mockEqAccount }); - vi.mocked(supabase.from).mockReturnValue({ select: mockSelect } as any); + // Mock getAccountOrganizations to return that the account IS a member + vi.mocked(getAccountOrganizations).mockResolvedValue([ + { + account_id: targetAccountId, + organization_id: orgId, + created_at: new Date().toISOString(), + organization: null, + }, + ]); const result = await canAccessAccount({ orgId, @@ -62,22 +52,18 @@ describe("canAccessAccount", () => { }); expect(result).toBe(true); - expect(supabase.from).toHaveBeenCalledWith("account_organization_ids"); + expect(getAccountOrganizations).toHaveBeenCalledWith({ + accountId: targetAccountId, + organizationId: orgId, + }); }); it("returns false when target account is NOT a member of the organization", async () => { const orgId = "org-456"; const targetAccountId = "non-member-account-999"; - // Mock supabase to return no match (not a member) - const mockMaybeSingle = vi.fn().mockResolvedValue({ - data: null, - error: null, - }); - const mockEqOrg = vi.fn().mockReturnValue({ maybeSingle: mockMaybeSingle }); - const mockEqAccount = vi.fn().mockReturnValue({ eq: mockEqOrg }); - const mockSelect = vi.fn().mockReturnValue({ eq: mockEqAccount }); - vi.mocked(supabase.from).mockReturnValue({ select: mockSelect } as any); + // Mock getAccountOrganizations to return empty array (not a member) + vi.mocked(getAccountOrganizations).mockResolvedValue([]); const result = await canAccessAccount({ orgId, @@ -85,6 +71,10 @@ describe("canAccessAccount", () => { }); expect(result).toBe(false); + expect(getAccountOrganizations).toHaveBeenCalledWith({ + accountId: targetAccountId, + organizationId: orgId, + }); }); }); @@ -96,7 +86,7 @@ describe("canAccessAccount", () => { }); expect(result).toBe(false); - expect(supabase.from).not.toHaveBeenCalled(); + expect(getAccountOrganizations).not.toHaveBeenCalled(); }); it("returns false when targetAccountId is empty", async () => { @@ -106,7 +96,7 @@ describe("canAccessAccount", () => { }); expect(result).toBe(false); - expect(supabase.from).not.toHaveBeenCalled(); + expect(getAccountOrganizations).not.toHaveBeenCalled(); }); it("returns false when both orgId and targetAccountId are invalid", async () => { @@ -118,28 +108,4 @@ describe("canAccessAccount", () => { expect(result).toBe(false); }); }); - - describe("error handling", () => { - it("returns false when database query throws an error", async () => { - const orgId = "org-456"; - const targetAccountId = "account-123"; - - // Mock supabase to return an error - const mockMaybeSingle = vi.fn().mockResolvedValue({ - data: null, - error: { message: "Database connection failed" }, - }); - const mockEqOrg = vi.fn().mockReturnValue({ maybeSingle: mockMaybeSingle }); - const mockEqAccount = vi.fn().mockReturnValue({ eq: mockEqOrg }); - const mockSelect = vi.fn().mockReturnValue({ eq: mockEqAccount }); - vi.mocked(supabase.from).mockReturnValue({ select: mockSelect } as any); - - const result = await canAccessAccount({ - orgId, - targetAccountId, - }); - - expect(result).toBe(false); - }); - }); }); diff --git a/lib/auth/canAccessAccount.ts b/lib/auth/canAccessAccount.ts index 04b0f48b..a3492c6d 100644 --- a/lib/auth/canAccessAccount.ts +++ b/lib/auth/canAccessAccount.ts @@ -1,5 +1,5 @@ import { RECOUP_ORG_ID } from "@/lib/const"; -import supabase from "@/lib/supabase/serverClient"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; export interface CanAccessAccountParams { orgId: string | null; @@ -33,16 +33,10 @@ export async function canAccessAccount( } // Check if target account is a member of the organization - const { data, error } = await supabase - .from("account_organization_ids") - .select("account_id") - .eq("account_id", targetAccountId) - .eq("organization_id", orgId) - .maybeSingle(); + const memberships = await getAccountOrganizations({ + accountId: targetAccountId, + organizationId: orgId, + }); - if (error) { - return false; - } - - return data !== null; + return memberships.length > 0; } From a843bedc17b0208b77bb50a6faef25ccd9c04942 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 14:22:04 -0500 Subject: [PATCH 09/12] refactor: use isOrganization supabase lib in getApiKeyDetails Extracted direct Supabase call from getApiKeyDetails.ts into a new isOrganization() function in lib/supabase/account_organization_ids/. This follows the architecture pattern of using supabase lib functions instead of direct queries. Co-Authored-By: Claude Opus 4.5 --- lib/auth/__tests__/getApiKeyDetails.test.ts | 33 ++---- lib/auth/getApiKeyDetails.ts | 13 +-- .../__tests__/isOrganization.test.ts | 100 ++++++++++++++++++ .../isOrganization.ts | 28 +++++ 4 files changed, 141 insertions(+), 33 deletions(-) create mode 100644 lib/supabase/account_organization_ids/__tests__/isOrganization.test.ts create mode 100644 lib/supabase/account_organization_ids/isOrganization.ts diff --git a/lib/auth/__tests__/getApiKeyDetails.test.ts b/lib/auth/__tests__/getApiKeyDetails.test.ts index b404725d..8936ddcf 100644 --- a/lib/auth/__tests__/getApiKeyDetails.test.ts +++ b/lib/auth/__tests__/getApiKeyDetails.test.ts @@ -14,20 +14,12 @@ vi.mock("@/lib/supabase/account_api_keys/selectAccountApiKeys", () => ({ selectAccountApiKeys: vi.fn(), })); -vi.mock("@/lib/supabase/serverClient", () => ({ - default: { - from: vi.fn(() => ({ - select: vi.fn(() => ({ - eq: vi.fn(() => ({ - maybeSingle: vi.fn(), - })), - })), - })), - }, +vi.mock("@/lib/supabase/account_organization_ids/isOrganization", () => ({ + isOrganization: vi.fn(), })); import { selectAccountApiKeys } from "@/lib/supabase/account_api_keys/selectAccountApiKeys"; -import supabase from "@/lib/supabase/serverClient"; +import { isOrganization } from "@/lib/supabase/account_organization_ids/isOrganization"; describe("getApiKeyDetails", () => { beforeEach(() => { @@ -49,11 +41,8 @@ describe("getApiKeyDetails", () => { }, ]); - // Mock supabase to return no organization (this is a personal key) - const mockMaybeSingle = vi.fn().mockResolvedValue({ data: null, error: null }); - const mockEq = vi.fn().mockReturnValue({ maybeSingle: mockMaybeSingle }); - const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }); - vi.mocked(supabase.from).mockReturnValue({ select: mockSelect } as any); + // Mock isOrganization to return false (this is a personal key) + vi.mocked(isOrganization).mockResolvedValue(false); const result = await getApiKeyDetails("test_api_key"); @@ -61,6 +50,7 @@ describe("getApiKeyDetails", () => { accountId: personalAccountId, orgId: null, }); + expect(isOrganization).toHaveBeenCalledWith(personalAccountId); }); it("returns accountId and orgId for organization API key", async () => { @@ -77,14 +67,8 @@ describe("getApiKeyDetails", () => { }, ]); - // Mock supabase to return that this account IS an organization - const mockMaybeSingle = vi.fn().mockResolvedValue({ - data: { organization_id: orgId }, - error: null, - }); - const mockEq = vi.fn().mockReturnValue({ maybeSingle: mockMaybeSingle }); - const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }); - vi.mocked(supabase.from).mockReturnValue({ select: mockSelect } as any); + // Mock isOrganization to return true (this is an org key) + vi.mocked(isOrganization).mockResolvedValue(true); const result = await getApiKeyDetails("org_api_key"); @@ -92,6 +76,7 @@ describe("getApiKeyDetails", () => { accountId: orgId, orgId: orgId, }); + expect(isOrganization).toHaveBeenCalledWith(orgId); }); }); diff --git a/lib/auth/getApiKeyDetails.ts b/lib/auth/getApiKeyDetails.ts index 44f0bd9f..d3475c28 100644 --- a/lib/auth/getApiKeyDetails.ts +++ b/lib/auth/getApiKeyDetails.ts @@ -1,7 +1,7 @@ import { hashApiKey } from "@/lib/keys/hashApiKey"; import { PRIVY_PROJECT_SECRET } from "@/lib/const"; import { selectAccountApiKeys } from "@/lib/supabase/account_api_keys/selectAccountApiKeys"; -import supabase from "@/lib/supabase/serverClient"; +import { isOrganization } from "@/lib/supabase/account_organization_ids/isOrganization"; export interface ApiKeyDetails { accountId: string; @@ -38,14 +38,9 @@ export async function getApiKeyDetails( return null; } - // Check if this account is an organization by looking it up in account_organization_ids - const { data: orgRecord } = await supabase - .from("account_organization_ids") - .select("organization_id") - .eq("organization_id", accountId) - .maybeSingle(); - - const orgId = orgRecord?.organization_id ?? null; + // Check if this account is an organization + const isOrg = await isOrganization(accountId); + const orgId = isOrg ? accountId : null; return { accountId, diff --git a/lib/supabase/account_organization_ids/__tests__/isOrganization.test.ts b/lib/supabase/account_organization_ids/__tests__/isOrganization.test.ts new file mode 100644 index 00000000..92c6f00b --- /dev/null +++ b/lib/supabase/account_organization_ids/__tests__/isOrganization.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { isOrganization } from "../isOrganization"; + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + maybeSingle: vi.fn(), + })), + })), + })), + }, +})); + +import supabase from "@/lib/supabase/serverClient"; + +describe("isOrganization", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("valid organization", () => { + it("returns true when accountId is an organization", async () => { + const orgId = "org-123"; + + const mockMaybeSingle = vi + .fn() + .mockResolvedValue({ data: { organization_id: orgId }, error: null }); + const mockEq = vi.fn().mockReturnValue({ maybeSingle: mockMaybeSingle }); + const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }); + vi.mocked(supabase.from).mockReturnValue({ select: mockSelect } as any); + + const result = await isOrganization(orgId); + + expect(result).toBe(true); + expect(supabase.from).toHaveBeenCalledWith("account_organization_ids"); + expect(mockSelect).toHaveBeenCalledWith("organization_id"); + expect(mockEq).toHaveBeenCalledWith("organization_id", orgId); + }); + }); + + describe("not an organization", () => { + it("returns false when accountId is not an organization", async () => { + const personalAccountId = "personal-123"; + + const mockMaybeSingle = vi + .fn() + .mockResolvedValue({ data: null, error: null }); + const mockEq = vi.fn().mockReturnValue({ maybeSingle: mockMaybeSingle }); + const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }); + vi.mocked(supabase.from).mockReturnValue({ select: mockSelect } as any); + + const result = await isOrganization(personalAccountId); + + expect(result).toBe(false); + }); + }); + + describe("invalid inputs", () => { + it("returns false for empty accountId", async () => { + const result = await isOrganization(""); + + expect(result).toBe(false); + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it("returns false for null accountId", async () => { + const result = await isOrganization(null as unknown as string); + + expect(result).toBe(false); + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it("returns false for undefined accountId", async () => { + const result = await isOrganization(undefined as unknown as string); + + expect(result).toBe(false); + expect(supabase.from).not.toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("returns false when database query fails", async () => { + const orgId = "org-123"; + + const mockMaybeSingle = vi.fn().mockResolvedValue({ + data: null, + error: { message: "Database error" }, + }); + const mockEq = vi.fn().mockReturnValue({ maybeSingle: mockMaybeSingle }); + const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }); + vi.mocked(supabase.from).mockReturnValue({ select: mockSelect } as any); + + const result = await isOrganization(orgId); + + expect(result).toBe(false); + }); + }); +}); diff --git a/lib/supabase/account_organization_ids/isOrganization.ts b/lib/supabase/account_organization_ids/isOrganization.ts new file mode 100644 index 00000000..99d5cab5 --- /dev/null +++ b/lib/supabase/account_organization_ids/isOrganization.ts @@ -0,0 +1,28 @@ +import supabase from "../serverClient"; + +/** + * Checks if an accountId is an organization. + * + * An account is considered an organization if it appears as the organization_id + * in the account_organization_ids table. + * + * @param accountId - The account ID to check + * @returns true if the account is an organization, false otherwise + */ +export async function isOrganization(accountId: string): Promise { + if (!accountId) { + return false; + } + + const { data, error } = await supabase + .from("account_organization_ids") + .select("organization_id") + .eq("organization_id", accountId) + .maybeSingle(); + + if (error) { + return false; + } + + return data !== null; +} From fe69a606c8bd1a0971359219217c987ee5e8e490 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 14:31:07 -0500 Subject: [PATCH 10/12] refactor: move canAccessAccount from lib/auth to lib/organizations canAccessAccount is about organization access control, not authentication. Moving it to lib/organizations for better code organization. - Moved lib/auth/canAccessAccount.ts to lib/organizations/canAccessAccount.ts - Moved tests to lib/organizations/__tests__/canAccessAccount.test.ts - Updated imports in createChatHandler.ts and its tests Co-Authored-By: Claude Opus 4.5 --- lib/chats/__tests__/createChatHandler.test.ts | 4 ++-- lib/chats/createChatHandler.ts | 2 +- .../__tests__/canAccessAccount.test.ts | 0 lib/{auth => organizations}/canAccessAccount.ts | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename lib/{auth => organizations}/__tests__/canAccessAccount.test.ts (100%) rename lib/{auth => organizations}/canAccessAccount.ts (100%) diff --git a/lib/chats/__tests__/createChatHandler.test.ts b/lib/chats/__tests__/createChatHandler.test.ts index a1860489..233da584 100644 --- a/lib/chats/__tests__/createChatHandler.test.ts +++ b/lib/chats/__tests__/createChatHandler.test.ts @@ -11,7 +11,7 @@ vi.mock("@/lib/auth/getApiKeyDetails", () => ({ getApiKeyDetails: vi.fn(), })); -vi.mock("@/lib/auth/canAccessAccount", () => ({ +vi.mock("@/lib/organizations/canAccessAccount", () => ({ canAccessAccount: vi.fn(), })); @@ -33,7 +33,7 @@ vi.mock("@/lib/networking/safeParseJson", () => ({ import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; import { getApiKeyDetails } from "@/lib/auth/getApiKeyDetails"; -import { canAccessAccount } from "@/lib/auth/canAccessAccount"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; import { safeParseJson } from "@/lib/networking/safeParseJson"; diff --git a/lib/chats/createChatHandler.ts b/lib/chats/createChatHandler.ts index cc473def..f9dd0abc 100644 --- a/lib/chats/createChatHandler.ts +++ b/lib/chats/createChatHandler.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; import { getApiKeyDetails } from "@/lib/auth/getApiKeyDetails"; -import { canAccessAccount } from "@/lib/auth/canAccessAccount"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; import { generateUUID } from "@/lib/uuid/generateUUID"; import { validateCreateChatBody } from "@/lib/chats/validateCreateChatBody"; diff --git a/lib/auth/__tests__/canAccessAccount.test.ts b/lib/organizations/__tests__/canAccessAccount.test.ts similarity index 100% rename from lib/auth/__tests__/canAccessAccount.test.ts rename to lib/organizations/__tests__/canAccessAccount.test.ts diff --git a/lib/auth/canAccessAccount.ts b/lib/organizations/canAccessAccount.ts similarity index 100% rename from lib/auth/canAccessAccount.ts rename to lib/organizations/canAccessAccount.ts From 84b9a3b9cf8b6373c265c8c2072008af3a79cfed Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 14:34:34 -0500 Subject: [PATCH 11/12] refactor: move getApiKeyDetails from lib/auth to lib/keys Move getApiKeyDetails to lib/keys since it's about API key operations, not authentication. Updated imports in createChatHandler and its tests. Co-Authored-By: Claude Opus 4.5 --- lib/chats/__tests__/createChatHandler.test.ts | 4 ++-- lib/chats/createChatHandler.ts | 2 +- lib/{auth => keys}/__tests__/getApiKeyDetails.test.ts | 0 lib/{auth => keys}/getApiKeyDetails.ts | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename lib/{auth => keys}/__tests__/getApiKeyDetails.test.ts (100%) rename lib/{auth => keys}/getApiKeyDetails.ts (100%) diff --git a/lib/chats/__tests__/createChatHandler.test.ts b/lib/chats/__tests__/createChatHandler.test.ts index 233da584..3f6f3632 100644 --- a/lib/chats/__tests__/createChatHandler.test.ts +++ b/lib/chats/__tests__/createChatHandler.test.ts @@ -7,7 +7,7 @@ vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ getApiKeyAccountId: vi.fn(), })); -vi.mock("@/lib/auth/getApiKeyDetails", () => ({ +vi.mock("@/lib/keys/getApiKeyDetails", () => ({ getApiKeyDetails: vi.fn(), })); @@ -32,7 +32,7 @@ vi.mock("@/lib/networking/safeParseJson", () => ({ })); import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; -import { getApiKeyDetails } from "@/lib/auth/getApiKeyDetails"; +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; import { safeParseJson } from "@/lib/networking/safeParseJson"; diff --git a/lib/chats/createChatHandler.ts b/lib/chats/createChatHandler.ts index f9dd0abc..c3e1b0c2 100644 --- a/lib/chats/createChatHandler.ts +++ b/lib/chats/createChatHandler.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; -import { getApiKeyDetails } from "@/lib/auth/getApiKeyDetails"; +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; import { generateUUID } from "@/lib/uuid/generateUUID"; diff --git a/lib/auth/__tests__/getApiKeyDetails.test.ts b/lib/keys/__tests__/getApiKeyDetails.test.ts similarity index 100% rename from lib/auth/__tests__/getApiKeyDetails.test.ts rename to lib/keys/__tests__/getApiKeyDetails.test.ts diff --git a/lib/auth/getApiKeyDetails.ts b/lib/keys/getApiKeyDetails.ts similarity index 100% rename from lib/auth/getApiKeyDetails.ts rename to lib/keys/getApiKeyDetails.ts From d601fdafcc5c79688904a547701516ad88d076b4 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 14 Jan 2026 14:53:58 -0500 Subject: [PATCH 12/12] refactor: extract validateOverrideAccountId and remove isOrganization - Create lib/accounts/validateOverrideAccountId.ts for SRP - Update createChatHandler to use validateOverrideAccountId - Replace isOrganization with getAccountOrganizations in getApiKeyDetails - Extend getAccountOrganizations to support organizationId-only queries - Delete isOrganization.ts and its tests Co-Authored-By: Claude Opus 4.5 --- .../validateOverrideAccountId.test.ts | 131 ++++++++++++++++++ lib/accounts/validateOverrideAccountId.ts | 78 +++++++++++ lib/chats/__tests__/createChatHandler.test.ts | 119 +++++----------- lib/chats/createChatHandler.ts | 52 +------ lib/keys/__tests__/getApiKeyDetails.test.ts | 32 +++-- lib/keys/getApiKeyDetails.ts | 12 +- .../__tests__/isOrganization.test.ts | 100 ------------- .../getAccountOrganizations.ts | 26 ++-- .../isOrganization.ts | 28 ---- 9 files changed, 289 insertions(+), 289 deletions(-) create mode 100644 lib/accounts/__tests__/validateOverrideAccountId.test.ts create mode 100644 lib/accounts/validateOverrideAccountId.ts delete mode 100644 lib/supabase/account_organization_ids/__tests__/isOrganization.test.ts delete mode 100644 lib/supabase/account_organization_ids/isOrganization.ts diff --git a/lib/accounts/__tests__/validateOverrideAccountId.test.ts b/lib/accounts/__tests__/validateOverrideAccountId.test.ts new file mode 100644 index 00000000..a6e37e52 --- /dev/null +++ b/lib/accounts/__tests__/validateOverrideAccountId.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; +import { validateOverrideAccountId } from "../validateOverrideAccountId"; + +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/keys/getApiKeyDetails", () => ({ + getApiKeyDetails: vi.fn(), +})); + +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: vi.fn(), +})); + +describe("validateOverrideAccountId", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("successful validation", () => { + it("returns accountId when org has access to target account", async () => { + const targetAccountId = "target-account-123"; + const orgId = "org-456"; + + vi.mocked(getApiKeyDetails).mockResolvedValue({ + accountId: orgId, + orgId: orgId, + }); + vi.mocked(canAccessAccount).mockResolvedValue(true); + + const result = await validateOverrideAccountId({ + apiKey: "valid_api_key", + targetAccountId, + }); + + expect(result).toEqual({ accountId: targetAccountId }); + expect(getApiKeyDetails).toHaveBeenCalledWith("valid_api_key"); + expect(canAccessAccount).toHaveBeenCalledWith({ + orgId, + targetAccountId, + }); + }); + }); + + describe("missing API key", () => { + it("returns 500 error when apiKey is null", async () => { + const result = await validateOverrideAccountId({ + apiKey: null, + targetAccountId: "target-123", + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(500); + + const body = await response.json(); + expect(body).toEqual({ + status: "error", + message: "Failed to validate API key", + }); + }); + }); + + describe("invalid API key", () => { + it("returns 500 error when getApiKeyDetails returns null", async () => { + vi.mocked(getApiKeyDetails).mockResolvedValue(null); + + const result = await validateOverrideAccountId({ + apiKey: "invalid_key", + targetAccountId: "target-123", + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(500); + + const body = await response.json(); + expect(body).toEqual({ + status: "error", + message: "Failed to validate API key", + }); + }); + }); + + describe("access denied", () => { + it("returns 403 error when org does not have access to target account", async () => { + vi.mocked(getApiKeyDetails).mockResolvedValue({ + accountId: "org-123", + orgId: "org-123", + }); + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const result = await validateOverrideAccountId({ + apiKey: "valid_key", + targetAccountId: "unauthorized-account", + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + + const body = await response.json(); + expect(body).toEqual({ + status: "error", + message: "Access denied to specified accountId", + }); + }); + + it("returns 403 error when API key is personal (no orgId)", async () => { + vi.mocked(getApiKeyDetails).mockResolvedValue({ + accountId: "personal-account", + orgId: null, + }); + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const result = await validateOverrideAccountId({ + apiKey: "personal_key", + targetAccountId: "some-account", + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + }); + }); +}); diff --git a/lib/accounts/validateOverrideAccountId.ts b/lib/accounts/validateOverrideAccountId.ts new file mode 100644 index 00000000..657a01b7 --- /dev/null +++ b/lib/accounts/validateOverrideAccountId.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; + +export type ValidateOverrideAccountIdParams = { + apiKey: string | null; + targetAccountId: string; +}; + +export type ValidateOverrideAccountIdResult = { + accountId: string; +}; + +/** + * Validates that an API key has permission to override to a target accountId. + * + * Used when an org API key wants to create resources on behalf of another account. + * Checks that the API key belongs to an org with access to the target account. + * + * @param params.apiKey - The x-api-key header value + * @param params.targetAccountId - The accountId to override to + * @param root0 + * @param root0.apiKey + * @param root0.targetAccountId + * @returns The validated accountId or a NextResponse error + */ +export async function validateOverrideAccountId({ + apiKey, + targetAccountId, +}: ValidateOverrideAccountIdParams): Promise { + if (!apiKey) { + return NextResponse.json( + { + status: "error", + message: "Failed to validate API key", + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } + + const keyDetails = await getApiKeyDetails(apiKey); + if (!keyDetails) { + return NextResponse.json( + { + status: "error", + message: "Failed to validate API key", + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } + + const hasAccess = await canAccessAccount({ + orgId: keyDetails.orgId, + targetAccountId, + }); + + if (!hasAccess) { + return NextResponse.json( + { + status: "error", + message: "Access denied to specified accountId", + }, + { + status: 403, + headers: getCorsHeaders(), + }, + ); + } + + return { accountId: targetAccountId }; +} diff --git a/lib/chats/__tests__/createChatHandler.test.ts b/lib/chats/__tests__/createChatHandler.test.ts index 3f6f3632..3d8b53f9 100644 --- a/lib/chats/__tests__/createChatHandler.test.ts +++ b/lib/chats/__tests__/createChatHandler.test.ts @@ -1,18 +1,19 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { createChatHandler } from "../createChatHandler"; +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; +import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; + // Mock dependencies vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ getApiKeyAccountId: vi.fn(), })); -vi.mock("@/lib/keys/getApiKeyDetails", () => ({ - getApiKeyDetails: vi.fn(), -})); - -vi.mock("@/lib/organizations/canAccessAccount", () => ({ - canAccessAccount: vi.fn(), +vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({ + validateOverrideAccountId: vi.fn(), })); vi.mock("@/lib/supabase/rooms/insertRoom", () => ({ @@ -31,12 +32,10 @@ vi.mock("@/lib/networking/safeParseJson", () => ({ safeParseJson: vi.fn(), })); -import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; -import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; -import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; -import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; -import { safeParseJson } from "@/lib/networking/safeParseJson"; - +/** + * + * @param apiKey + */ function createMockRequest(apiKey = "test-api-key"): NextRequest { return { headers: { @@ -70,6 +69,7 @@ describe("createChatHandler", () => { expect(response.status).toBe(200); expect(json.status).toBe("success"); + expect(validateOverrideAccountId).not.toHaveBeenCalled(); expect(insertRoom).toHaveBeenCalledWith({ id: "generated-uuid-123", account_id: apiKeyAccountId, @@ -80,7 +80,7 @@ describe("createChatHandler", () => { }); describe("with accountId override", () => { - it("uses body accountId when org has access (Recoup admin)", async () => { + it("uses body accountId when validation succeeds", async () => { const apiKeyAccountId = "recoup-org-account"; const targetAccountId = "123e4567-e89b-12d3-a456-426614174001"; const artistId = "123e4567-e89b-12d3-a456-426614174000"; @@ -90,11 +90,9 @@ describe("createChatHandler", () => { artistId, accountId: targetAccountId, }); - vi.mocked(getApiKeyDetails).mockResolvedValue({ - accountId: apiKeyAccountId, - orgId: "recoup-admin-org-id", + vi.mocked(validateOverrideAccountId).mockResolvedValue({ + accountId: targetAccountId, }); - vi.mocked(canAccessAccount).mockResolvedValue(true); vi.mocked(insertRoom).mockResolvedValue({ id: "generated-uuid-123", account_id: targetAccountId, @@ -108,8 +106,8 @@ describe("createChatHandler", () => { expect(response.status).toBe(200); expect(json.status).toBe("success"); - expect(canAccessAccount).toHaveBeenCalledWith({ - orgId: "recoup-admin-org-id", + expect(validateOverrideAccountId).toHaveBeenCalledWith({ + apiKey: "test-api-key", targetAccountId, }); expect(insertRoom).toHaveBeenCalledWith({ @@ -120,78 +118,20 @@ describe("createChatHandler", () => { }); }); - it("uses body accountId when org has access (org member)", async () => { + it("returns 403 when validation returns access denied", async () => { const apiKeyAccountId = "org-account"; const targetAccountId = "123e4567-e89b-12d3-a456-426614174001"; - const orgId = "regular-org-id"; vi.mocked(getApiKeyAccountId).mockResolvedValue(apiKeyAccountId); vi.mocked(safeParseJson).mockResolvedValue({ accountId: targetAccountId, }); - vi.mocked(getApiKeyDetails).mockResolvedValue({ - accountId: apiKeyAccountId, - orgId, - }); - vi.mocked(canAccessAccount).mockResolvedValue(true); - vi.mocked(insertRoom).mockResolvedValue({ - id: "generated-uuid-123", - account_id: targetAccountId, - artist_id: null, - topic: null, - }); - - const request = createMockRequest(); - const response = await createChatHandler(request); - const json = await response.json(); - - expect(response.status).toBe(200); - expect(json.status).toBe("success"); - expect(insertRoom).toHaveBeenCalledWith({ - id: "generated-uuid-123", - account_id: targetAccountId, - artist_id: null, - topic: null, - }); - }); - - it("returns 403 when org lacks access to target account", async () => { - const apiKeyAccountId = "org-account"; - const targetAccountId = "123e4567-e89b-12d3-a456-426614174001"; - const orgId = "regular-org-id"; - - vi.mocked(getApiKeyAccountId).mockResolvedValue(apiKeyAccountId); - vi.mocked(safeParseJson).mockResolvedValue({ - accountId: targetAccountId, - }); - vi.mocked(getApiKeyDetails).mockResolvedValue({ - accountId: apiKeyAccountId, - orgId, - }); - vi.mocked(canAccessAccount).mockResolvedValue(false); - - const request = createMockRequest(); - const response = await createChatHandler(request); - const json = await response.json(); - - expect(response.status).toBe(403); - expect(json.status).toBe("error"); - expect(json.message).toBe("Access denied to specified accountId"); - expect(insertRoom).not.toHaveBeenCalled(); - }); - - it("returns 403 when personal key tries to use accountId override", async () => { - const apiKeyAccountId = "personal-account-123"; - const targetAccountId = "123e4567-e89b-12d3-a456-426614174001"; - - vi.mocked(getApiKeyAccountId).mockResolvedValue(apiKeyAccountId); - vi.mocked(safeParseJson).mockResolvedValue({ - accountId: targetAccountId, - }); - vi.mocked(getApiKeyDetails).mockResolvedValue({ - accountId: apiKeyAccountId, - orgId: null, // Personal key has no org - }); + vi.mocked(validateOverrideAccountId).mockResolvedValue( + NextResponse.json( + { status: "error", message: "Access denied to specified accountId" }, + { status: 403 }, + ), + ); const request = createMockRequest(); const response = await createChatHandler(request); @@ -203,7 +143,7 @@ describe("createChatHandler", () => { expect(insertRoom).not.toHaveBeenCalled(); }); - it("returns 500 when getApiKeyDetails fails", async () => { + it("returns 500 when validation returns API key error", async () => { const apiKeyAccountId = "org-account"; const targetAccountId = "123e4567-e89b-12d3-a456-426614174001"; @@ -211,7 +151,12 @@ describe("createChatHandler", () => { vi.mocked(safeParseJson).mockResolvedValue({ accountId: targetAccountId, }); - vi.mocked(getApiKeyDetails).mockResolvedValue(null); + vi.mocked(validateOverrideAccountId).mockResolvedValue( + NextResponse.json( + { status: "error", message: "Failed to validate API key" }, + { status: 500 }, + ), + ); const request = createMockRequest(); const response = await createChatHandler(request); diff --git a/lib/chats/createChatHandler.ts b/lib/chats/createChatHandler.ts index c3e1b0c2..d80cf736 100644 --- a/lib/chats/createChatHandler.ts +++ b/lib/chats/createChatHandler.ts @@ -1,8 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; -import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; -import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; +import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; import { generateUUID } from "@/lib/uuid/generateUUID"; import { validateCreateChatBody } from "@/lib/chats/validateCreateChatBody"; @@ -38,53 +37,14 @@ export async function createChatHandler(request: NextRequest): Promise ({ hashApiKey: vi.fn((key: string) => `hashed_${key}`), @@ -14,13 +17,10 @@ vi.mock("@/lib/supabase/account_api_keys/selectAccountApiKeys", () => ({ selectAccountApiKeys: vi.fn(), })); -vi.mock("@/lib/supabase/account_organization_ids/isOrganization", () => ({ - isOrganization: vi.fn(), +vi.mock("@/lib/supabase/account_organization_ids/getAccountOrganizations", () => ({ + getAccountOrganizations: vi.fn(), })); -import { selectAccountApiKeys } from "@/lib/supabase/account_api_keys/selectAccountApiKeys"; -import { isOrganization } from "@/lib/supabase/account_organization_ids/isOrganization"; - describe("getApiKeyDetails", () => { beforeEach(() => { vi.clearAllMocks(); @@ -41,8 +41,8 @@ describe("getApiKeyDetails", () => { }, ]); - // Mock isOrganization to return false (this is a personal key) - vi.mocked(isOrganization).mockResolvedValue(false); + // Mock getAccountOrganizations to return empty array (not an org) + vi.mocked(getAccountOrganizations).mockResolvedValue([]); const result = await getApiKeyDetails("test_api_key"); @@ -50,7 +50,9 @@ describe("getApiKeyDetails", () => { accountId: personalAccountId, orgId: null, }); - expect(isOrganization).toHaveBeenCalledWith(personalAccountId); + expect(getAccountOrganizations).toHaveBeenCalledWith({ + organizationId: personalAccountId, + }); }); it("returns accountId and orgId for organization API key", async () => { @@ -67,8 +69,14 @@ describe("getApiKeyDetails", () => { }, ]); - // Mock isOrganization to return true (this is an org key) - vi.mocked(isOrganization).mockResolvedValue(true); + // Mock getAccountOrganizations to return members (is an org) + vi.mocked(getAccountOrganizations).mockResolvedValue([ + { + account_id: "member-1", + organization_id: orgId, + organization: null, + }, + ]); const result = await getApiKeyDetails("org_api_key"); @@ -76,7 +84,9 @@ describe("getApiKeyDetails", () => { accountId: orgId, orgId: orgId, }); - expect(isOrganization).toHaveBeenCalledWith(orgId); + expect(getAccountOrganizations).toHaveBeenCalledWith({ + organizationId: orgId, + }); }); }); diff --git a/lib/keys/getApiKeyDetails.ts b/lib/keys/getApiKeyDetails.ts index d3475c28..d4d3a7c5 100644 --- a/lib/keys/getApiKeyDetails.ts +++ b/lib/keys/getApiKeyDetails.ts @@ -1,7 +1,7 @@ import { hashApiKey } from "@/lib/keys/hashApiKey"; import { PRIVY_PROJECT_SECRET } from "@/lib/const"; import { selectAccountApiKeys } from "@/lib/supabase/account_api_keys/selectAccountApiKeys"; -import { isOrganization } from "@/lib/supabase/account_organization_ids/isOrganization"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; export interface ApiKeyDetails { accountId: string; @@ -17,9 +17,7 @@ export interface ApiKeyDetails { * @param apiKey - The raw API key string * @returns ApiKeyDetails object with accountId and orgId, or null if key is invalid */ -export async function getApiKeyDetails( - apiKey: string, -): Promise { +export async function getApiKeyDetails(apiKey: string): Promise { if (!apiKey) { return null; } @@ -38,9 +36,9 @@ export async function getApiKeyDetails( return null; } - // Check if this account is an organization - const isOrg = await isOrganization(accountId); - const orgId = isOrg ? accountId : null; + // Check if this account is an organization (has any members) + const members = await getAccountOrganizations({ organizationId: accountId }); + const orgId = members.length > 0 ? accountId : null; return { accountId, diff --git a/lib/supabase/account_organization_ids/__tests__/isOrganization.test.ts b/lib/supabase/account_organization_ids/__tests__/isOrganization.test.ts deleted file mode 100644 index 92c6f00b..00000000 --- a/lib/supabase/account_organization_ids/__tests__/isOrganization.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { isOrganization } from "../isOrganization"; - -vi.mock("@/lib/supabase/serverClient", () => ({ - default: { - from: vi.fn(() => ({ - select: vi.fn(() => ({ - eq: vi.fn(() => ({ - maybeSingle: vi.fn(), - })), - })), - })), - }, -})); - -import supabase from "@/lib/supabase/serverClient"; - -describe("isOrganization", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe("valid organization", () => { - it("returns true when accountId is an organization", async () => { - const orgId = "org-123"; - - const mockMaybeSingle = vi - .fn() - .mockResolvedValue({ data: { organization_id: orgId }, error: null }); - const mockEq = vi.fn().mockReturnValue({ maybeSingle: mockMaybeSingle }); - const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }); - vi.mocked(supabase.from).mockReturnValue({ select: mockSelect } as any); - - const result = await isOrganization(orgId); - - expect(result).toBe(true); - expect(supabase.from).toHaveBeenCalledWith("account_organization_ids"); - expect(mockSelect).toHaveBeenCalledWith("organization_id"); - expect(mockEq).toHaveBeenCalledWith("organization_id", orgId); - }); - }); - - describe("not an organization", () => { - it("returns false when accountId is not an organization", async () => { - const personalAccountId = "personal-123"; - - const mockMaybeSingle = vi - .fn() - .mockResolvedValue({ data: null, error: null }); - const mockEq = vi.fn().mockReturnValue({ maybeSingle: mockMaybeSingle }); - const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }); - vi.mocked(supabase.from).mockReturnValue({ select: mockSelect } as any); - - const result = await isOrganization(personalAccountId); - - expect(result).toBe(false); - }); - }); - - describe("invalid inputs", () => { - it("returns false for empty accountId", async () => { - const result = await isOrganization(""); - - expect(result).toBe(false); - expect(supabase.from).not.toHaveBeenCalled(); - }); - - it("returns false for null accountId", async () => { - const result = await isOrganization(null as unknown as string); - - expect(result).toBe(false); - expect(supabase.from).not.toHaveBeenCalled(); - }); - - it("returns false for undefined accountId", async () => { - const result = await isOrganization(undefined as unknown as string); - - expect(result).toBe(false); - expect(supabase.from).not.toHaveBeenCalled(); - }); - }); - - describe("error handling", () => { - it("returns false when database query fails", async () => { - const orgId = "org-123"; - - const mockMaybeSingle = vi.fn().mockResolvedValue({ - data: null, - error: { message: "Database error" }, - }); - const mockEq = vi.fn().mockReturnValue({ maybeSingle: mockMaybeSingle }); - const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }); - vi.mocked(supabase.from).mockReturnValue({ select: mockSelect } as any); - - const result = await isOrganization(orgId); - - expect(result).toBe(false); - }); - }); -}); diff --git a/lib/supabase/account_organization_ids/getAccountOrganizations.ts b/lib/supabase/account_organization_ids/getAccountOrganizations.ts index 87579520..92f33092 100644 --- a/lib/supabase/account_organization_ids/getAccountOrganizations.ts +++ b/lib/supabase/account_organization_ids/getAccountOrganizations.ts @@ -11,15 +11,20 @@ export type AccountOrganization = Tables<"account_organization_ids"> & { }; export interface GetAccountOrganizationsParams { - accountId: string; + accountId?: string; organizationId?: string; } /** - * Get all organizations an account belongs to. + * Get account organization relationships. + * + * Can query by: + * - accountId only: Get all organizations an account belongs to + * - organizationId only: Get all members of an organization + * - both: Check if a specific account belongs to a specific organization * * @param params - The parameters for the query - * @param params.accountId - The account ID to get organizations for + * @param params.accountId - Optional account ID to filter by * @param params.organizationId - Optional organization ID to filter by * @returns Array of organizations with their account info */ @@ -28,20 +33,21 @@ export async function getAccountOrganizations( ): Promise { const { accountId, organizationId } = params; - if (!accountId) return []; + if (!accountId && !organizationId) return []; - let query = supabase - .from("account_organization_ids") - .select( - ` + let query = supabase.from("account_organization_ids").select( + ` *, organization:accounts!account_organization_ids_organization_id_fkey ( *, account_info ( * ) ) `, - ) - .eq("account_id", accountId); + ); + + if (accountId) { + query = query.eq("account_id", accountId); + } if (organizationId) { query = query.eq("organization_id", organizationId); diff --git a/lib/supabase/account_organization_ids/isOrganization.ts b/lib/supabase/account_organization_ids/isOrganization.ts deleted file mode 100644 index 99d5cab5..00000000 --- a/lib/supabase/account_organization_ids/isOrganization.ts +++ /dev/null @@ -1,28 +0,0 @@ -import supabase from "../serverClient"; - -/** - * Checks if an accountId is an organization. - * - * An account is considered an organization if it appears as the organization_id - * in the account_organization_ids table. - * - * @param accountId - The account ID to check - * @returns true if the account is an organization, false otherwise - */ -export async function isOrganization(accountId: string): Promise { - if (!accountId) { - return false; - } - - const { data, error } = await supabase - .from("account_organization_ids") - .select("organization_id") - .eq("organization_id", accountId) - .maybeSingle(); - - if (error) { - return false; - } - - return data !== null; -}