From 19fe3c1addd1bb40847b3b07351ea6a7a7e91a0f Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Tue, 20 Jan 2026 14:40:55 -0500 Subject: [PATCH 1/3] feat: add POST /api/workspaces endpoint with centralized auth validation - Add POST /api/workspaces endpoint for workspace creation - Create validateAuthContext utility as single source of truth for auth/org validation - Fix personal API keys unable to add workspaces to orgs they're members of - Add self-access check allowing personal keys to specify own account_id - Refactor validateCreateArtistBody to use centralized utility + add org validation - Add comprehensive tests for validateAuthContext (15 tests) --- app/api/workspaces/route.ts | 42 ++ .../__tests__/createArtistPostHandler.test.ts | 77 ++-- .../validateCreateArtistBody.test.ts | 365 ++++++++++-------- lib/artists/validateCreateArtistBody.ts | 52 +-- .../__tests__/validateAuthContext.test.ts | 342 ++++++++++++++++ lib/auth/validateAuthContext.ts | 190 +++++++++ .../insertAccountWorkspaceId.ts | 30 ++ lib/workspaces/createWorkspaceInDb.ts | 55 +++ lib/workspaces/createWorkspacePostHandler.ts | 56 +++ lib/workspaces/validateCreateWorkspaceBody.ts | 71 ++++ 10 files changed, 1047 insertions(+), 233 deletions(-) create mode 100644 app/api/workspaces/route.ts create mode 100644 lib/auth/__tests__/validateAuthContext.test.ts create mode 100644 lib/auth/validateAuthContext.ts create mode 100644 lib/supabase/account_workspace_ids/insertAccountWorkspaceId.ts create mode 100644 lib/workspaces/createWorkspaceInDb.ts create mode 100644 lib/workspaces/createWorkspacePostHandler.ts create mode 100644 lib/workspaces/validateCreateWorkspaceBody.ts diff --git a/app/api/workspaces/route.ts b/app/api/workspaces/route.ts new file mode 100644 index 00000000..60d51a32 --- /dev/null +++ b/app/api/workspaces/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createWorkspacePostHandler } from "@/lib/workspaces/createWorkspacePostHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/workspaces + * + * Creates a new workspace account. + * + * Request body: + * - name (optional): The name of the workspace to create. Defaults to "Untitled". + * - account_id (optional): The ID of the account to create the workspace for (UUID). + * Only required for organization API keys creating workspaces on behalf of other accounts. + * - organization_id (optional): The organization ID to link the new workspace to (UUID). + * If provided, the workspace will appear in that organization's view. + * Access is validated to ensure the user has access to the organization. + * + * Response: + * - 201: { workspace: WorkspaceObject } + * - 400: { status: "error", error: "validation error message" } + * - 401: { status: "error", error: "x-api-key header required" or "Invalid API key" } + * - 403: { status: "error", error: "Access denied to specified organization_id/account_id" } + * - 500: { status: "error", error: "Failed to create workspace" } + * + * @param request - The request object containing JSON body + * @returns A NextResponse with the created workspace data (201) or error + */ +export async function POST(request: NextRequest) { + return createWorkspacePostHandler(request); +} diff --git a/lib/artists/__tests__/createArtistPostHandler.test.ts b/lib/artists/__tests__/createArtistPostHandler.test.ts index 78ee84a4..e63d244d 100644 --- a/lib/artists/__tests__/createArtistPostHandler.test.ts +++ b/lib/artists/__tests__/createArtistPostHandler.test.ts @@ -1,31 +1,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; + +import { createArtistPostHandler } from "../createArtistPostHandler"; const mockCreateArtistInDb = vi.fn(); -const mockGetApiKeyDetails = vi.fn(); -const mockCanAccessAccount = vi.fn(); +const mockValidateAuthContext = vi.fn(); vi.mock("@/lib/artists/createArtistInDb", () => ({ createArtistInDb: (...args: unknown[]) => mockCreateArtistInDb(...args), })); -vi.mock("@/lib/keys/getApiKeyDetails", () => ({ - getApiKeyDetails: (...args: unknown[]) => mockGetApiKeyDetails(...args), -})); - -vi.mock("@/lib/organizations/canAccessAccount", () => ({ - canAccessAccount: (...args: unknown[]) => mockCanAccessAccount(...args), +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args), })); -import { createArtistPostHandler } from "../createArtistPostHandler"; - -function createRequest(body: unknown, apiKey = "test-api-key"): NextRequest { +function createRequest(body: unknown, headers: Record = {}): NextRequest { + const defaultHeaders: Record = { + "Content-Type": "application/json", + "x-api-key": "test-api-key", + }; return new NextRequest("http://localhost/api/artists", { method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": apiKey, - }, + headers: { ...defaultHeaders, ...headers }, body: JSON.stringify(body), }); } @@ -33,13 +29,15 @@ function createRequest(body: unknown, apiKey = "test-api-key"): NextRequest { describe("createArtistPostHandler", () => { beforeEach(() => { vi.clearAllMocks(); - mockGetApiKeyDetails.mockResolvedValue({ + // Default mock: successful auth with personal API key + mockValidateAuthContext.mockResolvedValue({ accountId: "api-key-account-id", orgId: null, + authToken: "test-api-key", }); }); - it("creates artist using account_id from API key", async () => { + it("creates artist using account_id from auth context", async () => { const mockArtist = { id: "artist-123", account_id: "artist-123", @@ -63,11 +61,11 @@ describe("createArtistPostHandler", () => { }); it("uses account_id override for org API keys", async () => { - mockGetApiKeyDetails.mockResolvedValue({ - accountId: "org-account-id", + mockValidateAuthContext.mockResolvedValue({ + accountId: "550e8400-e29b-41d4-a716-446655440000", // Overridden account orgId: "org-account-id", + authToken: "test-api-key", }); - mockCanAccessAccount.mockResolvedValue(true); const mockArtist = { id: "artist-123", @@ -84,10 +82,6 @@ describe("createArtistPostHandler", () => { }); const response = await createArtistPostHandler(request); - expect(mockCanAccessAccount).toHaveBeenCalledWith({ - orgId: "org-account-id", - targetAccountId: "550e8400-e29b-41d4-a716-446655440000", - }); expect(mockCreateArtistInDb).toHaveBeenCalledWith( "Test Artist", "550e8400-e29b-41d4-a716-446655440000", @@ -97,11 +91,12 @@ describe("createArtistPostHandler", () => { }); it("returns 403 when org API key lacks access to account_id", async () => { - mockGetApiKeyDetails.mockResolvedValue({ - accountId: "org-account-id", - orgId: "org-account-id", - }); - mockCanAccessAccount.mockResolvedValue(false); + mockValidateAuthContext.mockResolvedValue( + NextResponse.json( + { status: "error", error: "Access denied to specified account_id" }, + { status: 403 }, + ), + ); const request = createRequest({ name: "Test Artist", @@ -136,7 +131,14 @@ describe("createArtistPostHandler", () => { ); }); - it("returns 401 when API key is missing", async () => { + it("returns 401 when auth is missing", async () => { + mockValidateAuthContext.mockResolvedValue( + NextResponse.json( + { status: "error", error: "Exactly one of x-api-key or Authorization must be provided" }, + { status: 401 }, + ), + ); + const request = new NextRequest("http://localhost/api/artists", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -147,18 +149,7 @@ describe("createArtistPostHandler", () => { const data = await response.json(); expect(response.status).toBe(401); - expect(data.error).toBe("x-api-key header required"); - }); - - it("returns 401 when API key is invalid", async () => { - mockGetApiKeyDetails.mockResolvedValue(null); - - const request = createRequest({ name: "Test Artist" }); - const response = await createArtistPostHandler(request); - const data = await response.json(); - - expect(response.status).toBe(401); - expect(data.error).toBe("Invalid API key"); + expect(data.error).toBe("Exactly one of x-api-key or Authorization must be provided"); }); it("returns 400 when name is missing", async () => { diff --git a/lib/artists/__tests__/validateCreateArtistBody.test.ts b/lib/artists/__tests__/validateCreateArtistBody.test.ts index cc619b08..4de5562b 100644 --- a/lib/artists/__tests__/validateCreateArtistBody.test.ts +++ b/lib/artists/__tests__/validateCreateArtistBody.test.ts @@ -1,27 +1,19 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; -const mockGetApiKeyDetails = vi.fn(); -const mockCanAccessAccount = vi.fn(); +import { validateCreateArtistBody } from "../validateCreateArtistBody"; -vi.mock("@/lib/keys/getApiKeyDetails", () => ({ - getApiKeyDetails: (...args: unknown[]) => mockGetApiKeyDetails(...args), -})); +const mockValidateAuthContext = vi.fn(); -vi.mock("@/lib/organizations/canAccessAccount", () => ({ - canAccessAccount: (...args: unknown[]) => mockCanAccessAccount(...args), +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args), })); -import { validateCreateArtistBody } from "../validateCreateArtistBody"; - -function createRequest(body: unknown, apiKey?: string): NextRequest { - const headers: Record = { "Content-Type": "application/json" }; - if (apiKey) { - headers["x-api-key"] = apiKey; - } +function createRequest(body: unknown, headers: Record = {}): NextRequest { + const defaultHeaders: Record = { "Content-Type": "application/json" }; return new NextRequest("http://localhost/api/artists", { method: "POST", - headers, + headers: { ...defaultHeaders, ...headers }, body: JSON.stringify(body), }); } @@ -29,173 +21,236 @@ function createRequest(body: unknown, apiKey?: string): NextRequest { describe("validateCreateArtistBody", () => { beforeEach(() => { vi.clearAllMocks(); - mockGetApiKeyDetails.mockResolvedValue({ + // Default mock: successful auth with personal API key + mockValidateAuthContext.mockResolvedValue({ accountId: "api-key-account-id", orgId: null, + authToken: "test-api-key", }); }); - it("returns validated data with accountId from API key", async () => { - const request = createRequest({ name: "Test Artist" }, "test-api-key"); - const result = await validateCreateArtistBody(request); - - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.name).toBe("Test Artist"); - expect(result.accountId).toBe("api-key-account-id"); - expect(result.organizationId).toBeUndefined(); - } - }); - - it("returns validated data with organization_id", async () => { - const request = createRequest( - { name: "Test Artist", organization_id: "660e8400-e29b-41d4-a716-446655440001" }, - "test-api-key", - ); - const result = await validateCreateArtistBody(request); - - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.name).toBe("Test Artist"); - expect(result.accountId).toBe("api-key-account-id"); - expect(result.organizationId).toBe("660e8400-e29b-41d4-a716-446655440001"); - } - }); - - it("uses account_id override for org API keys with access", async () => { - mockGetApiKeyDetails.mockResolvedValue({ - accountId: "org-account-id", - orgId: "org-account-id", + describe("successful validation", () => { + it("returns validated data with accountId from auth context", async () => { + const request = createRequest({ name: "Test Artist" }, { "x-api-key": "test-api-key" }); + const result = await validateCreateArtistBody(request); + + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.name).toBe("Test Artist"); + expect(result.accountId).toBe("api-key-account-id"); + expect(result.organizationId).toBeUndefined(); + } }); - mockCanAccessAccount.mockResolvedValue(true); - const request = createRequest( - { name: "Test Artist", account_id: "550e8400-e29b-41d4-a716-446655440000" }, - "test-api-key", - ); - const result = await validateCreateArtistBody(request); + it("returns validated data with organization_id", async () => { + mockValidateAuthContext.mockResolvedValue({ + accountId: "api-key-account-id", + orgId: "660e8400-e29b-41d4-a716-446655440001", + authToken: "test-api-key", + }); + + const request = createRequest( + { name: "Test Artist", organization_id: "660e8400-e29b-41d4-a716-446655440001" }, + { "x-api-key": "test-api-key" }, + ); + const result = await validateCreateArtistBody(request); + + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.name).toBe("Test Artist"); + expect(result.accountId).toBe("api-key-account-id"); + expect(result.organizationId).toBe("660e8400-e29b-41d4-a716-446655440001"); + } + }); - expect(mockCanAccessAccount).toHaveBeenCalledWith({ - orgId: "org-account-id", - targetAccountId: "550e8400-e29b-41d4-a716-446655440000", + it("uses account_id override for org API keys with access", async () => { + mockValidateAuthContext.mockResolvedValue({ + accountId: "550e8400-e29b-41d4-a716-446655440000", // Overridden account + orgId: "org-account-id", + authToken: "test-api-key", + }); + + const request = createRequest( + { name: "Test Artist", account_id: "550e8400-e29b-41d4-a716-446655440000" }, + { "x-api-key": "test-api-key" }, + ); + const result = await validateCreateArtistBody(request); + + // Verify validateAuthContext was called with account_id override + expect(mockValidateAuthContext).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + accountId: "550e8400-e29b-41d4-a716-446655440000", + }), + ); + + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.accountId).toBe("550e8400-e29b-41d4-a716-446655440000"); + } }); - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.accountId).toBe("550e8400-e29b-41d4-a716-446655440000"); - } }); - it("returns 403 when org API key lacks access to account_id", async () => { - mockGetApiKeyDetails.mockResolvedValue({ - accountId: "org-account-id", - orgId: "org-account-id", + describe("auth errors", () => { + it("returns 401 when auth is missing", async () => { + mockValidateAuthContext.mockResolvedValue( + NextResponse.json( + { status: "error", error: "Exactly one of x-api-key or Authorization must be provided" }, + { status: 401 }, + ), + ); + + const request = createRequest({ name: "Test Artist" }); + const result = await validateCreateArtistBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(401); + } }); - mockCanAccessAccount.mockResolvedValue(false); - - const request = createRequest( - { name: "Test Artist", account_id: "550e8400-e29b-41d4-a716-446655440000" }, - "test-api-key", - ); - const result = await validateCreateArtistBody(request); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(403); - const data = await result.json(); - expect(data.error).toBe("Access denied to specified account_id"); - } - }); - it("returns 401 when API key is missing", async () => { - const request = createRequest({ name: "Test Artist" }); - const result = await validateCreateArtistBody(request); + it("returns 403 when org API key lacks access to account_id", async () => { + mockValidateAuthContext.mockResolvedValue( + NextResponse.json( + { status: "error", error: "Access denied to specified account_id" }, + { status: 403 }, + ), + ); + + const request = createRequest( + { name: "Test Artist", account_id: "550e8400-e29b-41d4-a716-446655440000" }, + { "x-api-key": "test-api-key" }, + ); + const result = await validateCreateArtistBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(403); + const data = await result.json(); + expect(data.error).toBe("Access denied to specified account_id"); + } + }); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(401); - const data = await result.json(); - expect(data.error).toBe("x-api-key header required"); - } + it("returns 403 when account lacks access to organization_id", async () => { + mockValidateAuthContext.mockResolvedValue( + NextResponse.json( + { status: "error", error: "Access denied to specified organization_id" }, + { status: 403 }, + ), + ); + + const request = createRequest( + { name: "Test Artist", organization_id: "660e8400-e29b-41d4-a716-446655440001" }, + { "x-api-key": "test-api-key" }, + ); + const result = await validateCreateArtistBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(403); + const data = await result.json(); + expect(data.error).toBe("Access denied to specified organization_id"); + } + }); }); - it("returns 401 when API key is invalid", async () => { - mockGetApiKeyDetails.mockResolvedValue(null); - - const request = createRequest({ name: "Test Artist" }, "invalid-key"); - const result = await validateCreateArtistBody(request); + describe("schema validation errors", () => { + it("returns schema error for invalid JSON body (treated as empty)", async () => { + const request = new NextRequest("http://localhost/api/artists", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": "test-api-key", + }, + body: "invalid json", + }); + + const result = await validateCreateArtistBody(request); + + // safeParseJson returns {} for invalid JSON, so schema validation catches it + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const data = await result.json(); + expect(data.error).toBe("name is required"); + } + }); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(401); - const data = await result.json(); - expect(data.error).toBe("Invalid API key"); - } - }); + it("returns error when name is missing", async () => { + const request = createRequest({}, { "x-api-key": "test-api-key" }); + const result = await validateCreateArtistBody(request); - it("returns schema error for invalid JSON body (treated as empty)", async () => { - const request = new NextRequest("http://localhost/api/artists", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": "test-api-key", - }, - body: "invalid json", + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } }); - const result = await validateCreateArtistBody(request); + it("returns error when name is empty", async () => { + const request = createRequest({ name: "" }, { "x-api-key": "test-api-key" }); + const result = await validateCreateArtistBody(request); - // safeParseJson returns {} for invalid JSON, so schema validation catches it - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - const data = await result.json(); - expect(data.error).toBe("name is required"); - } - }); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); - it("returns error when name is missing", async () => { - const request = createRequest({}, "test-api-key"); - const result = await validateCreateArtistBody(request); + it("returns error when account_id is not a valid UUID", async () => { + const request = createRequest( + { name: "Test Artist", account_id: "invalid-uuid" }, + { "x-api-key": "test-api-key" }, + ); + const result = await validateCreateArtistBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } + it("returns error when organization_id is not a valid UUID", async () => { + const request = createRequest( + { name: "Test Artist", organization_id: "invalid-uuid" }, + { "x-api-key": "test-api-key" }, + ); + const result = await validateCreateArtistBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); }); - it("returns error when name is empty", async () => { - const request = createRequest({ name: "" }, "test-api-key"); - const result = await validateCreateArtistBody(request); + describe("auth context input", () => { + it("passes account_id and organization_id to validateAuthContext", async () => { + const request = createRequest( + { + name: "Test Artist", + account_id: "550e8400-e29b-41d4-a716-446655440000", + organization_id: "660e8400-e29b-41d4-a716-446655440001", + }, + { "x-api-key": "test-api-key" }, + ); + + await validateCreateArtistBody(request); + + expect(mockValidateAuthContext).toHaveBeenCalledWith(expect.anything(), { + accountId: "550e8400-e29b-41d4-a716-446655440000", + organizationId: "660e8400-e29b-41d4-a716-446655440001", + }); + }); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); + it("passes undefined for missing account_id and organization_id", async () => { + const request = createRequest({ name: "Test Artist" }, { "x-api-key": "test-api-key" }); - it("returns error when account_id is not a valid UUID", async () => { - const request = createRequest( - { name: "Test Artist", account_id: "invalid-uuid" }, - "test-api-key", - ); - const result = await validateCreateArtistBody(request); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); + await validateCreateArtistBody(request); - it("returns error when organization_id is not a valid UUID", async () => { - const request = createRequest( - { name: "Test Artist", organization_id: "invalid-uuid" }, - "test-api-key", - ); - const result = await validateCreateArtistBody(request); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } + expect(mockValidateAuthContext).toHaveBeenCalledWith(expect.anything(), { + accountId: undefined, + organizationId: undefined, + }); + }); }); }); diff --git a/lib/artists/validateCreateArtistBody.ts b/lib/artists/validateCreateArtistBody.ts index 6b4e0b83..2515d116 100644 --- a/lib/artists/validateCreateArtistBody.ts +++ b/lib/artists/validateCreateArtistBody.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; -import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { safeParseJson } from "@/lib/networking/safeParseJson"; import { z } from "zod"; @@ -20,8 +19,12 @@ export type ValidatedCreateArtistRequest = { }; /** - * Validates POST /api/artists request including API key, body parsing, schema validation, - * and account access authorization. + * Validates POST /api/artists request including auth headers, body parsing, schema validation, + * account access authorization, and organization access authorization. + * + * Supports both: + * - x-api-key header + * - Authorization: Bearer header * * @param request - The NextRequest object * @returns A NextResponse with an error if validation fails, or the validated request data if validation passes. @@ -29,22 +32,7 @@ export type ValidatedCreateArtistRequest = { export async function validateCreateArtistBody( request: NextRequest, ): Promise { - const apiKey = request.headers.get("x-api-key"); - if (!apiKey) { - return NextResponse.json( - { status: "error", error: "x-api-key header required" }, - { status: 401, headers: getCorsHeaders() }, - ); - } - - const keyDetails = await getApiKeyDetails(apiKey); - if (!keyDetails) { - return NextResponse.json( - { status: "error", error: "Invalid API key" }, - { status: 401, headers: getCorsHeaders() }, - ); - } - + // Parse and validate the request body first const body = await safeParseJson(request); const result = createArtistBodySchema.safeParse(body); if (!result.success) { @@ -59,25 +47,19 @@ export async function validateCreateArtistBody( ); } - // Use account_id from body if provided (org API keys only), otherwise use API key's account - let accountId = keyDetails.accountId; - if (result.data.account_id) { - const hasAccess = await canAccessAccount({ - orgId: keyDetails.orgId, - targetAccountId: result.data.account_id, - }); - if (!hasAccess) { - return NextResponse.json( - { status: "error", error: "Access denied to specified account_id" }, - { status: 403, headers: getCorsHeaders() }, - ); - } - accountId = result.data.account_id; + // Validate auth and authorization using the centralized utility + const authContext = await validateAuthContext(request, { + accountId: result.data.account_id, + organizationId: result.data.organization_id, + }); + + if (authContext instanceof NextResponse) { + return authContext; } return { name: result.data.name, - accountId, + accountId: authContext.accountId, organizationId: result.data.organization_id, }; } diff --git a/lib/auth/__tests__/validateAuthContext.test.ts b/lib/auth/__tests__/validateAuthContext.test.ts new file mode 100644 index 00000000..911ecbc4 --- /dev/null +++ b/lib/auth/__tests__/validateAuthContext.test.ts @@ -0,0 +1,342 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; +import { validateAuthContext } from "../validateAuthContext"; + +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; +import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; + +// Mock dependencies +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); + +vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ + getApiKeyAccountId: vi.fn(), +})); + +vi.mock("@/lib/auth/getAuthenticatedAccountId", () => ({ + getAuthenticatedAccountId: vi.fn(), +})); + +vi.mock("@/lib/keys/getApiKeyDetails", () => ({ + getApiKeyDetails: vi.fn(), +})); + +vi.mock("@/lib/organizations/validateOrganizationAccess", () => ({ + validateOrganizationAccess: vi.fn(), +})); + +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: vi.fn(), +})); + +const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); +const mockGetAuthenticatedAccountId = vi.mocked(getAuthenticatedAccountId); +const mockGetApiKeyDetails = vi.mocked(getApiKeyDetails); +const mockValidateOrganizationAccess = vi.mocked(validateOrganizationAccess); +const mockCanAccessAccount = vi.mocked(canAccessAccount); + +function createMockRequest(headers: Record = {}): Request { + return { + headers: { + get: (name: string) => headers[name.toLowerCase()] || null, + }, + } as unknown as Request; +} + +describe("validateAuthContext", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("authentication mechanism validation", () => { + it("returns 401 when neither x-api-key nor Authorization is provided", async () => { + const request = createMockRequest({}); + + const result = await validateAuthContext(request as never); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("Exactly one of x-api-key or Authorization must be provided"); + }); + + it("returns 401 when both x-api-key and Authorization are provided", async () => { + const request = createMockRequest({ + "x-api-key": "test-api-key", + authorization: "Bearer test-token", + }); + + const result = await validateAuthContext(request as never); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(401); + }); + }); + + describe("API key authentication", () => { + it("returns accountId from API key when valid", async () => { + const request = createMockRequest({ "x-api-key": "valid-api-key" }); + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "account-123", + orgId: null, + name: "test-key", + }); + + const result = await validateAuthContext(request as never); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: "account-123", + orgId: null, + authToken: "valid-api-key", + }); + }); + + it("returns orgId from API key details when present", async () => { + const request = createMockRequest({ "x-api-key": "org-api-key" }); + mockGetApiKeyAccountId.mockResolvedValue("org-account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "org-account-123", + orgId: "org-456", + name: "org-key", + }); + + const result = await validateAuthContext(request as never); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: "org-account-123", + orgId: "org-456", + authToken: "org-api-key", + }); + }); + + it("returns error response when API key is invalid", async () => { + const request = createMockRequest({ "x-api-key": "invalid-key" }); + mockGetApiKeyAccountId.mockResolvedValue( + NextResponse.json({ error: "Invalid API key" }, { status: 401 }), + ); + + const result = await validateAuthContext(request as never); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(401); + }); + }); + + describe("Bearer token authentication", () => { + it("returns accountId from bearer token when valid", async () => { + const request = createMockRequest({ authorization: "Bearer valid-token" }); + mockGetAuthenticatedAccountId.mockResolvedValue("bearer-account-123"); + + const result = await validateAuthContext(request as never); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: "bearer-account-123", + orgId: null, + authToken: "valid-token", + }); + }); + + it("strips Bearer prefix from auth token", async () => { + const request = createMockRequest({ authorization: "Bearer my-token-123" }); + mockGetAuthenticatedAccountId.mockResolvedValue("account-123"); + + const result = await validateAuthContext(request as never); + + expect(result).not.toBeInstanceOf(NextResponse); + const authContext = result as { authToken: string }; + expect(authContext.authToken).toBe("my-token-123"); + }); + }); + + describe("account_id override", () => { + it("allows personal API key to specify own account_id (self-access)", async () => { + const request = createMockRequest({ "x-api-key": "personal-key" }); + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "account-123", + orgId: null, + name: "personal-key", + }); + + const result = await validateAuthContext(request as never, { + accountId: "account-123", // Same as the API key's account + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: "account-123", + orgId: null, + authToken: "personal-key", + }); + // Should not call canAccessAccount for self-access + expect(mockCanAccessAccount).not.toHaveBeenCalled(); + }); + + it("denies personal API key accessing different account_id", async () => { + const request = createMockRequest({ "x-api-key": "personal-key" }); + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "account-123", + orgId: null, + name: "personal-key", + }); + + const result = await validateAuthContext(request as never, { + accountId: "different-account-456", // Different from API key's account + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error).toBe("Access denied to specified account_id"); + }); + + it("allows org API key to access member account", async () => { + const request = createMockRequest({ "x-api-key": "org-key" }); + mockGetApiKeyAccountId.mockResolvedValue("org-account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "org-account-123", + orgId: "org-456", + name: "org-key", + }); + mockCanAccessAccount.mockResolvedValue(true); + + const result = await validateAuthContext(request as never, { + accountId: "member-account-789", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: "member-account-789", + orgId: "org-456", + authToken: "org-key", + }); + expect(mockCanAccessAccount).toHaveBeenCalledWith({ + orgId: "org-456", + targetAccountId: "member-account-789", + }); + }); + + it("denies org API key accessing non-member account", async () => { + const request = createMockRequest({ "x-api-key": "org-key" }); + mockGetApiKeyAccountId.mockResolvedValue("org-account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "org-account-123", + orgId: "org-456", + name: "org-key", + }); + mockCanAccessAccount.mockResolvedValue(false); + + const result = await validateAuthContext(request as never, { + accountId: "non-member-account", + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + }); + }); + + describe("organization_id validation", () => { + it("allows access when account is a member of the organization", async () => { + const request = createMockRequest({ "x-api-key": "personal-key" }); + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "account-123", + orgId: null, + name: "personal-key", + }); + mockValidateOrganizationAccess.mockResolvedValue(true); + + const result = await validateAuthContext(request as never, { + organizationId: "org-789", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: "account-123", + orgId: "org-789", // orgId is set from organizationId input + authToken: "personal-key", + }); + expect(mockValidateOrganizationAccess).toHaveBeenCalledWith({ + accountId: "account-123", + organizationId: "org-789", + }); + }); + + it("denies access when account is NOT a member of the organization", async () => { + const request = createMockRequest({ "x-api-key": "personal-key" }); + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "account-123", + orgId: null, + name: "personal-key", + }); + mockValidateOrganizationAccess.mockResolvedValue(false); + + const result = await validateAuthContext(request as never, { + organizationId: "org-not-member", + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error).toBe("Access denied to specified organization_id"); + }); + + it("skips organization validation when organizationId is null", async () => { + const request = createMockRequest({ "x-api-key": "personal-key" }); + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "account-123", + orgId: null, + name: "personal-key", + }); + + const result = await validateAuthContext(request as never, { + organizationId: null, + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(mockValidateOrganizationAccess).not.toHaveBeenCalled(); + }); + }); + + describe("combined account_id and organization_id validation", () => { + it("validates organization access using the overridden accountId", async () => { + const request = createMockRequest({ "x-api-key": "org-key" }); + mockGetApiKeyAccountId.mockResolvedValue("org-admin-account"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "org-admin-account", + orgId: "org-123", + name: "org-key", + }); + mockCanAccessAccount.mockResolvedValue(true); + mockValidateOrganizationAccess.mockResolvedValue(true); + + const result = await validateAuthContext(request as never, { + accountId: "member-account-456", + organizationId: "different-org-789", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + // Verify that organization access was validated with the overridden accountId + expect(mockValidateOrganizationAccess).toHaveBeenCalledWith({ + accountId: "member-account-456", // The overridden account + organizationId: "different-org-789", + }); + }); + }); +}); diff --git a/lib/auth/validateAuthContext.ts b/lib/auth/validateAuthContext.ts new file mode 100644 index 00000000..ae844dc1 --- /dev/null +++ b/lib/auth/validateAuthContext.ts @@ -0,0 +1,190 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; +import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; + +/** + * Input parameters for auth context validation. + * These typically come from the request body after schema validation. + */ +export interface AuthContextInput { + /** Optional account_id override from request body */ + accountId?: string; + /** Optional organization_id from request body */ + organizationId?: string | null; +} + +/** + * Validated auth context result. + * Contains the resolved accountId, orgId, and auth token. + */ +export interface AuthContext { + /** The resolved account ID (from API key or override) */ + accountId: string; + /** The organization context (from API key or request body) */ + orgId: string | null; + /** The auth token for forwarding to downstream services */ + authToken: string; +} + +/** + * Validates authentication headers and authorization context for API requests. + * + * This is the single source of truth for: + * 1. Authenticating via x-api-key or Authorization bearer token + * 2. Resolving the accountId (from auth or body override) + * 3. Validating account_id override access (org keys can access member accounts, personal keys can access own account) + * 4. Validating organization_id access (account must be a member of the org) + * + * @param request - The NextRequest object + * @param input - Optional overrides from the request body + * @returns A NextResponse with an error or the validated AuthContext + */ +export async function validateAuthContext( + request: NextRequest, + input: AuthContextInput = {}, +): Promise { + const apiKey = request.headers.get("x-api-key"); + const authHeader = request.headers.get("authorization"); + const hasApiKey = !!apiKey; + const hasAuth = !!authHeader; + + // Enforce exactly one auth mechanism + if ((hasApiKey && hasAuth) || (!hasApiKey && !hasAuth)) { + return NextResponse.json( + { status: "error", error: "Exactly one of x-api-key or Authorization must be provided" }, + { status: 401, headers: getCorsHeaders() }, + ); + } + + let accountId: string; + let orgId: string | null = null; + let authToken: string; + + if (hasApiKey) { + // Validate API key authentication + const accountIdOrError = await getApiKeyAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + accountId = accountIdOrError; + authToken = apiKey!; + + // Get org context from API key details + const keyDetails = await getApiKeyDetails(apiKey!); + if (keyDetails) { + orgId = keyDetails.orgId; + } + } else { + // Validate bearer token authentication + const accountIdOrError = await getAuthenticatedAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + accountId = accountIdOrError; + authToken = authHeader!.replace(/^Bearer\s+/i, ""); + } + + // Handle account_id override from request body + if (input.accountId) { + const overrideResult = await validateAccountIdOverride({ + currentAccountId: accountId, + targetAccountId: input.accountId, + orgId, + }); + + if (overrideResult instanceof NextResponse) { + return overrideResult; + } + + accountId = overrideResult.accountId; + } + + // Handle organization_id from request body + if (input.organizationId) { + const hasOrgAccess = await validateOrganizationAccess({ + accountId, + organizationId: input.organizationId, + }); + + if (!hasOrgAccess) { + return NextResponse.json( + { status: "error", error: "Access denied to specified organization_id" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + // Use the provided organizationId as the org context + orgId = input.organizationId; + } + + return { + accountId, + orgId, + authToken, + }; +} + +/** + * Parameters for account ID override validation. + */ +interface ValidateAccountIdOverrideParams { + /** The account ID from the authenticated API key/token */ + currentAccountId: string; + /** The target account ID to override to */ + targetAccountId: string; + /** The organization ID from the API key (null for personal keys) */ + orgId: string | null; +} + +/** + * Result of successful account ID override validation. + */ +interface ValidateAccountIdOverrideResult { + accountId: string; +} + +/** + * Validates if an account_id override is allowed. + * + * Access rules: + * 1. If targetAccountId equals currentAccountId, always allowed (self-access) + * 2. If orgId is present, checks if targetAccountId is a member of the org + * 3. If orgId is null and targetAccountId !== currentAccountId, denied + * + * @param params - The validation parameters + * @returns NextResponse with error or the validated result + */ +async function validateAccountIdOverride( + params: ValidateAccountIdOverrideParams, +): Promise { + const { currentAccountId, targetAccountId, orgId } = params; + + // Self-access is always allowed (personal API key accessing own account) + if (targetAccountId === currentAccountId) { + return { accountId: targetAccountId }; + } + + // For org API keys, check if target account is a member of the org + if (orgId) { + const hasAccess = await canAccessAccount({ + orgId, + targetAccountId, + }); + + if (hasAccess) { + return { accountId: targetAccountId }; + } + } + + // No access - either personal key trying to access another account, + // or org key trying to access a non-member account + return NextResponse.json( + { status: "error", error: "Access denied to specified account_id" }, + { status: 403, headers: getCorsHeaders() }, + ); +} diff --git a/lib/supabase/account_workspace_ids/insertAccountWorkspaceId.ts b/lib/supabase/account_workspace_ids/insertAccountWorkspaceId.ts new file mode 100644 index 00000000..9c7ad0e4 --- /dev/null +++ b/lib/supabase/account_workspace_ids/insertAccountWorkspaceId.ts @@ -0,0 +1,30 @@ +import supabase from "../serverClient"; + +/** + * Link a workspace to an owner account. + * + * @param accountId - The owner's account ID + * @param workspaceId - The workspace account ID + * @returns The created record ID, or null if failed + */ +export async function insertAccountWorkspaceId( + accountId: string, + workspaceId: string, +): Promise { + if (!accountId || !workspaceId) return null; + + const { data, error } = await supabase + .from("account_workspace_ids") + .insert({ + account_id: accountId, + workspace_id: workspaceId, + }) + .select("id") + .single(); + + if (error) { + return null; + } + + return data?.id || null; +} diff --git a/lib/workspaces/createWorkspaceInDb.ts b/lib/workspaces/createWorkspaceInDb.ts new file mode 100644 index 00000000..d7684c5b --- /dev/null +++ b/lib/workspaces/createWorkspaceInDb.ts @@ -0,0 +1,55 @@ +import { insertAccount } from "@/lib/supabase/accounts/insertAccount"; +import { insertAccountInfo } from "@/lib/supabase/account_info/insertAccountInfo"; +import { + selectAccountWithSocials, + type AccountWithSocials, +} from "@/lib/supabase/accounts/selectAccountWithSocials"; +import { insertAccountWorkspaceId } from "@/lib/supabase/account_workspace_ids/insertAccountWorkspaceId"; +import { addArtistToOrganization } from "@/lib/supabase/artist_organization_ids/addArtistToOrganization"; + +/** + * Result of creating a workspace in the database. + */ +export type CreateWorkspaceResult = AccountWithSocials & { + account_id: string; + isWorkspace: boolean; +}; + +/** + * Create a new workspace account in the database and associate it with an owner account. + * + * @param name - Name of the workspace to create + * @param accountId - ID of the owner account that will have access to this workspace + * @param organizationId - Optional organization ID to link the new workspace to + * @returns Created workspace object or null if creation failed + */ +export async function createWorkspaceInDb( + name: string, + accountId: string, + organizationId?: string, +): Promise { + try { + const account = await insertAccount({ name }); + + const accountInfo = await insertAccountInfo({ account_id: account.id }); + if (!accountInfo) return null; + + const workspace = await selectAccountWithSocials(account.id); + if (!workspace) return null; + + const linkId = await insertAccountWorkspaceId(accountId, account.id); + if (!linkId) return null; + + if (organizationId) { + await addArtistToOrganization(account.id, organizationId); + } + + return { + ...workspace, + account_id: workspace.id, + isWorkspace: true, + }; + } catch (error) { + return null; + } +} diff --git a/lib/workspaces/createWorkspacePostHandler.ts b/lib/workspaces/createWorkspacePostHandler.ts new file mode 100644 index 00000000..8acf4894 --- /dev/null +++ b/lib/workspaces/createWorkspacePostHandler.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateCreateWorkspaceBody } from "@/lib/workspaces/validateCreateWorkspaceBody"; +import { createWorkspaceInDb } from "@/lib/workspaces/createWorkspaceInDb"; + +/** + * Handler for POST /api/workspaces. + * + * Creates a new workspace account. Requires authentication via x-api-key header. + * The account ID is inferred from the API key, unless an account_id is provided + * in the request body by an organization API key with access to that account. + * + * Request body: + * - name (optional): The name of the workspace to create. Defaults to "Untitled". + * - account_id (optional): The ID of the account to create the workspace for (UUID). + * Only used by organization API keys creating workspaces on behalf of other accounts. + * - organization_id (optional): The organization ID to link the new workspace to (UUID). + * If provided, the workspace will appear in that organization's view. + * + * @param request - The request object containing JSON body + * @returns A NextResponse with workspace data or error + */ +export async function createWorkspacePostHandler( + request: NextRequest, +): Promise { + const validated = await validateCreateWorkspaceBody(request); + if (validated instanceof NextResponse) { + return validated; + } + + try { + const workspace = await createWorkspaceInDb( + validated.name, + validated.accountId, + validated.organizationId, + ); + + if (!workspace) { + return NextResponse.json( + { status: "error", error: "Failed to create workspace" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { workspace }, + { status: 201, headers: getCorsHeaders() }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to create workspace"; + return NextResponse.json( + { status: "error", error: message }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/workspaces/validateCreateWorkspaceBody.ts b/lib/workspaces/validateCreateWorkspaceBody.ts new file mode 100644 index 00000000..7833846b --- /dev/null +++ b/lib/workspaces/validateCreateWorkspaceBody.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { z } from "zod"; + +export const createWorkspaceBodySchema = z.object({ + name: z.string().optional(), + account_id: z.uuid({ message: "account_id must be a valid UUID" }).optional(), + organization_id: z + .uuid({ message: "organization_id must be a valid UUID" }) + .optional() + .nullable(), +}); + +export type CreateWorkspaceBody = z.infer; + +export type ValidatedCreateWorkspaceRequest = { + name: string; + accountId: string; + organizationId?: string; +}; + +/** + * Validates POST /api/workspaces request including auth headers, body parsing, schema validation, + * organization access authorization, and account access authorization. + * + * Supports both: + * - x-api-key header + * - Authorization: Bearer header + * + * @param request - The NextRequest object + * @returns A NextResponse with an error if validation fails, or the validated request data if validation passes. + */ +export async function validateCreateWorkspaceBody( + request: NextRequest, +): Promise { + // Parse and validate the request body first + const body = await safeParseJson(request); + const result = createWorkspaceBodySchema.safeParse(body); + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + // Validate auth and authorization using the centralized utility + const authContext = await validateAuthContext(request, { + accountId: result.data.account_id, + organizationId: result.data.organization_id, + }); + + if (authContext instanceof NextResponse) { + return authContext; + } + + // Default name to "Untitled" if not provided + const workspaceName = result.data.name?.trim() || "Untitled"; + + return { + name: workspaceName, + accountId: authContext.accountId, + organizationId: result.data.organization_id ?? undefined, + }; +} From 62df2b404fde8e88950144cf6c05f25364646505 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Tue, 20 Jan 2026 14:46:10 -0500 Subject: [PATCH 2/3] refactor: extract validateAccountIdOverride to own file (SRP) --- lib/auth/validateAccountIdOverride.ts | 63 +++++++++++++++++++++++++++ lib/auth/validateAuthContext.ts | 62 +------------------------- 2 files changed, 64 insertions(+), 61 deletions(-) create mode 100644 lib/auth/validateAccountIdOverride.ts diff --git a/lib/auth/validateAccountIdOverride.ts b/lib/auth/validateAccountIdOverride.ts new file mode 100644 index 00000000..ca4880a7 --- /dev/null +++ b/lib/auth/validateAccountIdOverride.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; + +/** + * Parameters for account ID override validation. + */ +export interface ValidateAccountIdOverrideParams { + /** The account ID from the authenticated API key/token */ + currentAccountId: string; + /** The target account ID to override to */ + targetAccountId: string; + /** The organization ID from the API key (null for personal keys) */ + orgId: string | null; +} + +/** + * Result of successful account ID override validation. + */ +export interface ValidateAccountIdOverrideResult { + accountId: string; +} + +/** + * Validates if an account_id override is allowed. + * + * Access rules: + * 1. If targetAccountId equals currentAccountId, always allowed (self-access) + * 2. If orgId is present, checks if targetAccountId is a member of the org + * 3. If orgId is null and targetAccountId !== currentAccountId, denied + * + * @param params - The validation parameters + * @returns NextResponse with error or the validated result + */ +export async function validateAccountIdOverride( + params: ValidateAccountIdOverrideParams, +): Promise { + const { currentAccountId, targetAccountId, orgId } = params; + + // Self-access is always allowed (personal API key accessing own account) + if (targetAccountId === currentAccountId) { + return { accountId: targetAccountId }; + } + + // For org API keys, check if target account is a member of the org + if (orgId) { + const hasAccess = await canAccessAccount({ + orgId, + targetAccountId, + }); + + if (hasAccess) { + return { accountId: targetAccountId }; + } + } + + // No access - either personal key trying to access another account, + // or org key trying to access a non-member account + return NextResponse.json( + { status: "error", error: "Access denied to specified account_id" }, + { status: 403, headers: getCorsHeaders() }, + ); +} diff --git a/lib/auth/validateAuthContext.ts b/lib/auth/validateAuthContext.ts index ae844dc1..5b42d393 100644 --- a/lib/auth/validateAuthContext.ts +++ b/lib/auth/validateAuthContext.ts @@ -5,7 +5,7 @@ import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; -import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; +import { validateAccountIdOverride } from "@/lib/auth/validateAccountIdOverride"; /** * Input parameters for auth context validation. @@ -128,63 +128,3 @@ export async function validateAuthContext( authToken, }; } - -/** - * Parameters for account ID override validation. - */ -interface ValidateAccountIdOverrideParams { - /** The account ID from the authenticated API key/token */ - currentAccountId: string; - /** The target account ID to override to */ - targetAccountId: string; - /** The organization ID from the API key (null for personal keys) */ - orgId: string | null; -} - -/** - * Result of successful account ID override validation. - */ -interface ValidateAccountIdOverrideResult { - accountId: string; -} - -/** - * Validates if an account_id override is allowed. - * - * Access rules: - * 1. If targetAccountId equals currentAccountId, always allowed (self-access) - * 2. If orgId is present, checks if targetAccountId is a member of the org - * 3. If orgId is null and targetAccountId !== currentAccountId, denied - * - * @param params - The validation parameters - * @returns NextResponse with error or the validated result - */ -async function validateAccountIdOverride( - params: ValidateAccountIdOverrideParams, -): Promise { - const { currentAccountId, targetAccountId, orgId } = params; - - // Self-access is always allowed (personal API key accessing own account) - if (targetAccountId === currentAccountId) { - return { accountId: targetAccountId }; - } - - // For org API keys, check if target account is a member of the org - if (orgId) { - const hasAccess = await canAccessAccount({ - orgId, - targetAccountId, - }); - - if (hasAccess) { - return { accountId: targetAccountId }; - } - } - - // No access - either personal key trying to access another account, - // or org key trying to access a non-member account - return NextResponse.json( - { status: "error", error: "Access denied to specified account_id" }, - { status: 403, headers: getCorsHeaders() }, - ); -} From 692e250e54d0f8817cfbf0b5cee59feea0f29051 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 20 Jan 2026 14:55:18 -0500 Subject: [PATCH 3/3] test: mock setupConversation to fix Supabase env errors Add setupConversation mock to validateChatRequest.test.ts and handleChatGenerate.test.ts to break the import chain that was reaching the Supabase server client and throwing errors due to missing SUPABASE_URL and SUPABASE_KEY environment variables. Co-Authored-By: Claude Opus 4.5 --- lib/chat/__tests__/handleChatGenerate.test.ts | 43 +++++- .../__tests__/validateChatRequest.test.ts | 144 +++++++++++------- 2 files changed, 128 insertions(+), 59 deletions(-) diff --git a/lib/chat/__tests__/handleChatGenerate.test.ts b/lib/chat/__tests__/handleChatGenerate.test.ts index 8ee88272..5449c4ec 100644 --- a/lib/chat/__tests__/handleChatGenerate.test.ts +++ b/lib/chat/__tests__/handleChatGenerate.test.ts @@ -54,6 +54,10 @@ vi.mock("@/lib/messages/filterMessageContentForMemories", () => ({ default: vi.fn((msg: unknown) => msg), })); +vi.mock("@/lib/chat/setupConversation", () => ({ + setupConversation: vi.fn(), +})); + import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; import { setupChatRequest } from "@/lib/chat/setupChatRequest"; @@ -61,6 +65,7 @@ import { generateText } from "ai"; import { saveChatCompletion } from "@/lib/chat/saveChatCompletion"; import { generateUUID } from "@/lib/uuid/generateUUID"; import { createNewRoom } from "@/lib/chat/createNewRoom"; +import { setupConversation } from "@/lib/chat/setupConversation"; import { handleChatGenerate } from "../handleChatGenerate"; const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); @@ -70,6 +75,7 @@ const mockGenerateText = vi.mocked(generateText); const mockSaveChatCompletion = vi.mocked(saveChatCompletion); const mockGenerateUUID = vi.mocked(generateUUID); const mockCreateNewRoom = vi.mocked(createNewRoom); +const mockSetupConversation = vi.mocked(setupConversation); // Helper to create mock NextRequest function createMockRequest( @@ -88,6 +94,11 @@ function createMockRequest( describe("handleChatGenerate", () => { beforeEach(() => { vi.clearAllMocks(); + // Default mock for setupConversation + mockSetupConversation.mockResolvedValue({ + roomId: "auto-generated-room-id", + memoryId: "auto-generated-memory-id", + }); }); afterEach(() => { @@ -204,6 +215,10 @@ describe("handleChatGenerate", () => { it("passes through optional parameters", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockSetupConversation.mockResolvedValue({ + roomId: "room-xyz", + memoryId: "memory-id", + }); mockSetupChatRequest.mockResolvedValue({ model: "claude-3-opus", @@ -370,6 +385,10 @@ describe("handleChatGenerate", () => { describe("message persistence", () => { it("saves assistant message to database when roomId is provided", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockSetupConversation.mockResolvedValue({ + roomId: "room-abc-123", + memoryId: "memory-id", + }); mockSetupChatRequest.mockResolvedValue({ model: "gpt-4", @@ -405,8 +424,10 @@ describe("handleChatGenerate", () => { it("saves message with auto-generated roomId when roomId is not provided", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); - mockGenerateUUID.mockReturnValue("auto-generated-room-id"); - mockCreateNewRoom.mockResolvedValue(undefined); + mockSetupConversation.mockResolvedValue({ + roomId: "auto-generated-room-id", + memoryId: "memory-id", + }); mockSetupChatRequest.mockResolvedValue({ model: "gpt-4", @@ -443,6 +464,10 @@ describe("handleChatGenerate", () => { it("includes roomId in HTTP response when provided by client", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockSetupConversation.mockResolvedValue({ + roomId: "client-provided-room-id", + memoryId: "memory-id", + }); mockSetupChatRequest.mockResolvedValue({ model: "gpt-4", @@ -477,8 +502,10 @@ describe("handleChatGenerate", () => { it("includes auto-generated roomId in HTTP response when not provided", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); - mockGenerateUUID.mockReturnValue("auto-generated-room-456"); - mockCreateNewRoom.mockResolvedValue(undefined); + mockSetupConversation.mockResolvedValue({ + roomId: "auto-generated-room-456", + memoryId: "memory-id", + }); mockSetupChatRequest.mockResolvedValue({ model: "gpt-4", @@ -513,6 +540,10 @@ describe("handleChatGenerate", () => { it("passes correct text to saveChatCompletion", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockSetupConversation.mockResolvedValue({ + roomId: "room-xyz", + memoryId: "memory-id", + }); mockSetupChatRequest.mockResolvedValue({ model: "gpt-4", @@ -548,6 +579,10 @@ describe("handleChatGenerate", () => { it("still returns success response even if saveChatCompletion fails", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockSetupConversation.mockResolvedValue({ + roomId: "room-abc", + memoryId: "memory-id", + }); mockSetupChatRequest.mockResolvedValue({ model: "gpt-4", diff --git a/lib/chat/__tests__/validateChatRequest.test.ts b/lib/chat/__tests__/validateChatRequest.test.ts index 29f704e8..59a9fe2b 100644 --- a/lib/chat/__tests__/validateChatRequest.test.ts +++ b/lib/chat/__tests__/validateChatRequest.test.ts @@ -43,6 +43,10 @@ vi.mock("@/lib/messages/filterMessageContentForMemories", () => ({ default: vi.fn((msg: unknown) => msg), })); +vi.mock("@/lib/chat/setupConversation", () => ({ + setupConversation: vi.fn(), +})); + import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; @@ -52,6 +56,7 @@ import { generateUUID } from "@/lib/uuid/generateUUID"; import { createNewRoom } from "@/lib/chat/createNewRoom"; import insertMemories from "@/lib/supabase/memories/insertMemories"; import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories"; +import { setupConversation } from "@/lib/chat/setupConversation"; const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); const mockGetAuthenticatedAccountId = vi.mocked(getAuthenticatedAccountId); @@ -62,6 +67,7 @@ const mockGenerateUUID = vi.mocked(generateUUID); const mockCreateNewRoom = vi.mocked(createNewRoom); const mockInsertMemories = vi.mocked(insertMemories); const mockFilterMessageContentForMemories = vi.mocked(filterMessageContentForMemories); +const mockSetupConversation = vi.mocked(setupConversation); // Helper to create mock NextRequest function createMockRequest(body: unknown, headers: Record = {}): Request { @@ -77,6 +83,11 @@ function createMockRequest(body: unknown, headers: Record = {}): describe("validateChatRequest", () => { beforeEach(() => { vi.clearAllMocks(); + // Default mock for setupConversation returns generated roomId and memoryId + mockSetupConversation.mockResolvedValue({ + roomId: "mock-uuid-default", + memoryId: "mock-uuid-default", + }); }); describe("schema validation", () => { @@ -374,6 +385,10 @@ describe("validateChatRequest", () => { describe("optional fields", () => { it("passes through roomId", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockSetupConversation.mockResolvedValue({ + roomId: "room-xyz", + memoryId: "memory-id", + }); const request = createMockRequest( { prompt: "Hello", roomId: "room-xyz" }, @@ -576,10 +591,12 @@ describe("validateChatRequest", () => { }); describe("auto room creation", () => { - it("generates a new roomId when roomId is not provided", async () => { + it("returns roomId from setupConversation when roomId is not provided", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); - mockGenerateUUID.mockReturnValue("generated-uuid-456"); - mockCreateNewRoom.mockResolvedValue(undefined); + mockSetupConversation.mockResolvedValue({ + roomId: "generated-uuid-456", + memoryId: "memory-id", + }); const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "test-key" }); @@ -587,53 +604,58 @@ describe("validateChatRequest", () => { expect(result).not.toBeInstanceOf(NextResponse); expect((result as any).roomId).toBe("generated-uuid-456"); - expect(mockGenerateUUID).toHaveBeenCalled(); }); - it("creates a new room when roomId is not provided", async () => { + it("calls setupConversation with correct params when roomId is not provided", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); - mockGenerateUUID.mockReturnValue("generated-uuid-789"); - mockCreateNewRoom.mockResolvedValue(undefined); + mockSetupConversation.mockResolvedValue({ + roomId: "generated-uuid-789", + memoryId: "memory-id", + }); const request = createMockRequest({ prompt: "Create a new room" }, { "x-api-key": "test-key" }); - const result = await validateChatRequest(request as any); + await validateChatRequest(request as any); - expect(result).not.toBeInstanceOf(NextResponse); - expect(mockCreateNewRoom).toHaveBeenCalledWith({ + expect(mockSetupConversation).toHaveBeenCalledWith({ accountId: "account-123", - roomId: "generated-uuid-789", - artistId: undefined, - lastMessage: expect.objectContaining({ + roomId: undefined, + promptMessage: expect.objectContaining({ role: "user", parts: expect.arrayContaining([expect.objectContaining({ text: "Create a new room" })]), }), + artistId: undefined, + memoryId: expect.any(String), }); }); - it("creates a new room with artistId when provided", async () => { + it("passes artistId to setupConversation when provided", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); - mockGenerateUUID.mockReturnValue("generated-uuid-abc"); - mockCreateNewRoom.mockResolvedValue(undefined); + mockSetupConversation.mockResolvedValue({ + roomId: "generated-uuid-abc", + memoryId: "memory-id", + }); const request = createMockRequest( { prompt: "Hello", artistId: "artist-xyz" }, { "x-api-key": "test-key" }, ); - const result = await validateChatRequest(request as any); + await validateChatRequest(request as any); - expect(result).not.toBeInstanceOf(NextResponse); - expect(mockCreateNewRoom).toHaveBeenCalledWith({ - accountId: "account-123", - roomId: "generated-uuid-abc", - artistId: "artist-xyz", - lastMessage: expect.any(Object), - }); + expect(mockSetupConversation).toHaveBeenCalledWith( + expect.objectContaining({ + artistId: "artist-xyz", + }), + ); }); - it("does NOT generate a new roomId when roomId is provided", async () => { + it("returns provided roomId when roomId is provided", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockSetupConversation.mockResolvedValue({ + roomId: "existing-room-123", + memoryId: "memory-id", + }); const request = createMockRequest( { prompt: "Hello", roomId: "existing-room-123" }, @@ -644,12 +666,14 @@ describe("validateChatRequest", () => { expect(result).not.toBeInstanceOf(NextResponse); expect((result as any).roomId).toBe("existing-room-123"); - // Note: generateUUID may be called by getMessages for message IDs, but not for roomId - expect(mockCreateNewRoom).not.toHaveBeenCalled(); }); - it("does NOT create a room when roomId is already provided", async () => { + it("passes roomId to setupConversation when provided", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); + mockSetupConversation.mockResolvedValue({ + roomId: "existing-room-456", + memoryId: "memory-id", + }); const request = createMockRequest( { prompt: "Hello", roomId: "existing-room-456" }, @@ -658,13 +682,19 @@ describe("validateChatRequest", () => { await validateChatRequest(request as any); - expect(mockCreateNewRoom).not.toHaveBeenCalled(); + expect(mockSetupConversation).toHaveBeenCalledWith( + expect.objectContaining({ + roomId: "existing-room-456", + }), + ); }); it("works with bearer token auth for auto room creation", async () => { mockGetAuthenticatedAccountId.mockResolvedValue("jwt-account-123"); - mockGenerateUUID.mockReturnValue("jwt-generated-uuid"); - mockCreateNewRoom.mockResolvedValue(undefined); + mockSetupConversation.mockResolvedValue({ + roomId: "jwt-generated-uuid", + memoryId: "memory-id", + }); const request = createMockRequest( { prompt: "Hello from JWT" }, @@ -675,19 +705,19 @@ describe("validateChatRequest", () => { expect(result).not.toBeInstanceOf(NextResponse); expect((result as any).roomId).toBe("jwt-generated-uuid"); - expect(mockCreateNewRoom).toHaveBeenCalledWith({ - accountId: "jwt-account-123", - roomId: "jwt-generated-uuid", - artistId: undefined, - lastMessage: expect.any(Object), - }); + expect(mockSetupConversation).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "jwt-account-123", + }), + ); }); - it("persists user message to memories when roomId is auto-created", async () => { + it("calls setupConversation when roomId is auto-created", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); - mockGenerateUUID.mockReturnValue("new-room-uuid"); - mockCreateNewRoom.mockResolvedValue(undefined); - mockInsertMemories.mockResolvedValue(null); + mockSetupConversation.mockResolvedValue({ + roomId: "new-room-uuid", + memoryId: "new-room-uuid", + }); const request = createMockRequest( { prompt: "This is my first message" }, @@ -696,22 +726,26 @@ describe("validateChatRequest", () => { await validateChatRequest(request as any); - expect(mockInsertMemories).toHaveBeenCalledWith({ - id: "new-room-uuid", - room_id: "new-room-uuid", - content: expect.objectContaining({ + expect(mockSetupConversation).toHaveBeenCalledWith({ + accountId: "account-123", + roomId: undefined, + promptMessage: expect.objectContaining({ role: "user", parts: expect.arrayContaining([ expect.objectContaining({ text: "This is my first message" }), ]), }), + artistId: undefined, + memoryId: expect.any(String), }); }); - it("persists user message to memories for existing rooms (match /api/emails/inbound behavior)", async () => { + it("calls setupConversation for existing rooms", async () => { mockGetApiKeyAccountId.mockResolvedValue("account-123"); - mockGenerateUUID.mockReturnValue("memory-uuid"); - mockInsertMemories.mockResolvedValue(null); + mockSetupConversation.mockResolvedValue({ + roomId: "existing-room-id", + memoryId: "memory-uuid", + }); const request = createMockRequest( { prompt: "Hello to existing room", roomId: "existing-room-id" }, @@ -720,19 +754,19 @@ describe("validateChatRequest", () => { await validateChatRequest(request as any); - // User message should be persisted for ALL requests (matching email flow) - expect(mockInsertMemories).toHaveBeenCalledWith({ - id: "memory-uuid", - room_id: "existing-room-id", - content: expect.objectContaining({ + // setupConversation handles both new and existing rooms + expect(mockSetupConversation).toHaveBeenCalledWith({ + accountId: "account-123", + roomId: "existing-room-id", + promptMessage: expect.objectContaining({ role: "user", parts: expect.arrayContaining([ expect.objectContaining({ text: "Hello to existing room" }), ]), }), + artistId: undefined, + memoryId: expect.any(String), }); - // Room should NOT be created for existing rooms - expect(mockCreateNewRoom).not.toHaveBeenCalled(); }); }); });