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/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 new file mode 100644 index 00000000..5b42d393 --- /dev/null +++ b/lib/auth/validateAuthContext.ts @@ -0,0 +1,130 @@ +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 { validateAccountIdOverride } from "@/lib/auth/validateAccountIdOverride"; + +/** + * 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, + }; +} 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(); }); }); }); 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, + }; +}