diff --git a/CLAUDE.md b/CLAUDE.md index 20dc37f2..ea5b2671 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,90 @@ pnpm format:check # Check formatting - `lib/trigger/` - Trigger.dev task triggers - `lib/x402/` - Payment middleware utilities +## Supabase Database Operations + +**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 + +``` +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. 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 new file mode 100644 index 00000000..3d8b53f9 --- /dev/null +++ b/lib/chats/__tests__/createChatHandler.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +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/accounts/validateOverrideAccountId", () => ({ + validateOverrideAccountId: 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(), +})); + +/** + * + * @param apiKey + */ +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(validateOverrideAccountId).not.toHaveBeenCalled(); + expect(insertRoom).toHaveBeenCalledWith({ + id: "generated-uuid-123", + account_id: apiKeyAccountId, + artist_id: artistId, + topic: null, + }); + }); + }); + + describe("with accountId override", () => { + 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"; + + vi.mocked(getApiKeyAccountId).mockResolvedValue(apiKeyAccountId); + vi.mocked(safeParseJson).mockResolvedValue({ + artistId, + accountId: targetAccountId, + }); + vi.mocked(validateOverrideAccountId).mockResolvedValue({ + accountId: targetAccountId, + }); + 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(validateOverrideAccountId).toHaveBeenCalledWith({ + apiKey: "test-api-key", + targetAccountId, + }); + expect(insertRoom).toHaveBeenCalledWith({ + id: "generated-uuid-123", + account_id: targetAccountId, + artist_id: artistId, + topic: null, + }); + }); + + it("returns 403 when validation returns access denied", 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(validateOverrideAccountId).mockResolvedValue( + NextResponse.json( + { status: "error", message: "Access denied to specified accountId" }, + { status: 403 }, + ), + ); + + 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 validation returns API key error", 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(validateOverrideAccountId).mockResolvedValue( + NextResponse.json( + { status: "error", message: "Failed to validate API key" }, + { status: 500 }, + ), + ); + + 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/__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/createChatHandler.ts b/lib/chats/createChatHandler.ts index ad1c592f..d80cf736 100644 --- a/lib/chats/createChatHandler.ts +++ b/lib/chats/createChatHandler.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +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"; @@ -10,7 +11,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 +24,7 @@ export async function createChatHandler(request: NextRequest): Promise; 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"; diff --git a/lib/keys/__tests__/getApiKeyDetails.test.ts b/lib/keys/__tests__/getApiKeyDetails.test.ts new file mode 100644 index 00000000..a1525791 --- /dev/null +++ b/lib/keys/__tests__/getApiKeyDetails.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getApiKeyDetails } from "../getApiKeyDetails"; + +import { selectAccountApiKeys } from "@/lib/supabase/account_api_keys/selectAccountApiKeys"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; + +// 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/account_organization_ids/getAccountOrganizations", () => ({ + getAccountOrganizations: vi.fn(), +})); + +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 getAccountOrganizations to return empty array (not an org) + vi.mocked(getAccountOrganizations).mockResolvedValue([]); + + const result = await getApiKeyDetails("test_api_key"); + + expect(result).toEqual({ + accountId: personalAccountId, + orgId: null, + }); + expect(getAccountOrganizations).toHaveBeenCalledWith({ + organizationId: personalAccountId, + }); + }); + + 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 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"); + + expect(result).toEqual({ + accountId: orgId, + orgId: orgId, + }); + expect(getAccountOrganizations).toHaveBeenCalledWith({ + organizationId: 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/keys/getApiKeyDetails.ts b/lib/keys/getApiKeyDetails.ts new file mode 100644 index 00000000..d4d3a7c5 --- /dev/null +++ b/lib/keys/getApiKeyDetails.ts @@ -0,0 +1,51 @@ +import { hashApiKey } from "@/lib/keys/hashApiKey"; +import { PRIVY_PROJECT_SECRET } from "@/lib/const"; +import { selectAccountApiKeys } from "@/lib/supabase/account_api_keys/selectAccountApiKeys"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; + +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 (has any members) + const members = await getAccountOrganizations({ organizationId: accountId }); + const orgId = members.length > 0 ? accountId : null; + + return { + accountId, + orgId, + }; + } catch (error) { + console.error("[ERROR] getApiKeyDetails:", error); + return null; + } +} diff --git a/lib/organizations/__tests__/canAccessAccount.test.ts b/lib/organizations/__tests__/canAccessAccount.test.ts new file mode 100644 index 00000000..54f5a231 --- /dev/null +++ b/lib/organizations/__tests__/canAccessAccount.test.ts @@ -0,0 +1,111 @@ +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 getAccountOrganizations supabase lib +vi.mock("@/lib/supabase/account_organization_ids/getAccountOrganizations", () => ({ + getAccountOrganizations: vi.fn(), +})); + +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; + +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(getAccountOrganizations).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 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, + targetAccountId, + }); + + expect(result).toBe(true); + 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 getAccountOrganizations to return empty array (not a member) + vi.mocked(getAccountOrganizations).mockResolvedValue([]); + + const result = await canAccessAccount({ + orgId, + targetAccountId, + }); + + expect(result).toBe(false); + expect(getAccountOrganizations).toHaveBeenCalledWith({ + accountId: targetAccountId, + organizationId: orgId, + }); + }); + }); + + 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(getAccountOrganizations).not.toHaveBeenCalled(); + }); + + it("returns false when targetAccountId is empty", async () => { + const result = await canAccessAccount({ + orgId: "org-456", + targetAccountId: "", + }); + + expect(result).toBe(false); + expect(getAccountOrganizations).not.toHaveBeenCalled(); + }); + + it("returns false when both orgId and targetAccountId are invalid", async () => { + const result = await canAccessAccount({ + orgId: null, + targetAccountId: "", + }); + + expect(result).toBe(false); + }); + }); +}); diff --git a/lib/organizations/canAccessAccount.ts b/lib/organizations/canAccessAccount.ts new file mode 100644 index 00000000..a3492c6d --- /dev/null +++ b/lib/organizations/canAccessAccount.ts @@ -0,0 +1,42 @@ +import { RECOUP_ORG_ID } from "@/lib/const"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; + +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 memberships = await getAccountOrganizations({ + accountId: targetAccountId, + organizationId: orgId, + }); + + return memberships.length > 0; +} 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);