Skip to content

Commit fb8b7a9

Browse files
sidneyswiftsweetmantechclaude
authored
feat: add POST /api/workspaces endpoint with centralized auth validation (#141)
* 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) * refactor: extract validateAccountIdOverride to own file (SRP) * 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 <noreply@anthropic.com> --------- Co-authored-by: Sweets Sweetman <sweetmantech@gmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c991b55 commit fb8b7a9

13 files changed

+1178
-292
lines changed

app/api/workspaces/route.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
3+
import { createWorkspacePostHandler } from "@/lib/workspaces/createWorkspacePostHandler";
4+
5+
/**
6+
* OPTIONS handler for CORS preflight requests.
7+
*
8+
* @returns A NextResponse with CORS headers.
9+
*/
10+
export async function OPTIONS() {
11+
return new NextResponse(null, {
12+
status: 200,
13+
headers: getCorsHeaders(),
14+
});
15+
}
16+
17+
/**
18+
* POST /api/workspaces
19+
*
20+
* Creates a new workspace account.
21+
*
22+
* Request body:
23+
* - name (optional): The name of the workspace to create. Defaults to "Untitled".
24+
* - account_id (optional): The ID of the account to create the workspace for (UUID).
25+
* Only required for organization API keys creating workspaces on behalf of other accounts.
26+
* - organization_id (optional): The organization ID to link the new workspace to (UUID).
27+
* If provided, the workspace will appear in that organization's view.
28+
* Access is validated to ensure the user has access to the organization.
29+
*
30+
* Response:
31+
* - 201: { workspace: WorkspaceObject }
32+
* - 400: { status: "error", error: "validation error message" }
33+
* - 401: { status: "error", error: "x-api-key header required" or "Invalid API key" }
34+
* - 403: { status: "error", error: "Access denied to specified organization_id/account_id" }
35+
* - 500: { status: "error", error: "Failed to create workspace" }
36+
*
37+
* @param request - The request object containing JSON body
38+
* @returns A NextResponse with the created workspace data (201) or error
39+
*/
40+
export async function POST(request: NextRequest) {
41+
return createWorkspacePostHandler(request);
42+
}

lib/artists/__tests__/createArtistPostHandler.test.ts

Lines changed: 34 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,43 @@
11
import { describe, it, expect, vi, beforeEach } from "vitest";
2-
import { NextRequest } from "next/server";
2+
import { NextRequest, NextResponse } from "next/server";
3+
4+
import { createArtistPostHandler } from "../createArtistPostHandler";
35

46
const mockCreateArtistInDb = vi.fn();
5-
const mockGetApiKeyDetails = vi.fn();
6-
const mockCanAccessAccount = vi.fn();
7+
const mockValidateAuthContext = vi.fn();
78

89
vi.mock("@/lib/artists/createArtistInDb", () => ({
910
createArtistInDb: (...args: unknown[]) => mockCreateArtistInDb(...args),
1011
}));
1112

12-
vi.mock("@/lib/keys/getApiKeyDetails", () => ({
13-
getApiKeyDetails: (...args: unknown[]) => mockGetApiKeyDetails(...args),
14-
}));
15-
16-
vi.mock("@/lib/organizations/canAccessAccount", () => ({
17-
canAccessAccount: (...args: unknown[]) => mockCanAccessAccount(...args),
13+
vi.mock("@/lib/auth/validateAuthContext", () => ({
14+
validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args),
1815
}));
1916

20-
import { createArtistPostHandler } from "../createArtistPostHandler";
21-
22-
function createRequest(body: unknown, apiKey = "test-api-key"): NextRequest {
17+
function createRequest(body: unknown, headers: Record<string, string> = {}): NextRequest {
18+
const defaultHeaders: Record<string, string> = {
19+
"Content-Type": "application/json",
20+
"x-api-key": "test-api-key",
21+
};
2322
return new NextRequest("http://localhost/api/artists", {
2423
method: "POST",
25-
headers: {
26-
"Content-Type": "application/json",
27-
"x-api-key": apiKey,
28-
},
24+
headers: { ...defaultHeaders, ...headers },
2925
body: JSON.stringify(body),
3026
});
3127
}
3228

3329
describe("createArtistPostHandler", () => {
3430
beforeEach(() => {
3531
vi.clearAllMocks();
36-
mockGetApiKeyDetails.mockResolvedValue({
32+
// Default mock: successful auth with personal API key
33+
mockValidateAuthContext.mockResolvedValue({
3734
accountId: "api-key-account-id",
3835
orgId: null,
36+
authToken: "test-api-key",
3937
});
4038
});
4139

42-
it("creates artist using account_id from API key", async () => {
40+
it("creates artist using account_id from auth context", async () => {
4341
const mockArtist = {
4442
id: "artist-123",
4543
account_id: "artist-123",
@@ -63,11 +61,11 @@ describe("createArtistPostHandler", () => {
6361
});
6462

6563
it("uses account_id override for org API keys", async () => {
66-
mockGetApiKeyDetails.mockResolvedValue({
67-
accountId: "org-account-id",
64+
mockValidateAuthContext.mockResolvedValue({
65+
accountId: "550e8400-e29b-41d4-a716-446655440000", // Overridden account
6866
orgId: "org-account-id",
67+
authToken: "test-api-key",
6968
});
70-
mockCanAccessAccount.mockResolvedValue(true);
7169

7270
const mockArtist = {
7371
id: "artist-123",
@@ -84,10 +82,6 @@ describe("createArtistPostHandler", () => {
8482
});
8583
const response = await createArtistPostHandler(request);
8684

87-
expect(mockCanAccessAccount).toHaveBeenCalledWith({
88-
orgId: "org-account-id",
89-
targetAccountId: "550e8400-e29b-41d4-a716-446655440000",
90-
});
9185
expect(mockCreateArtistInDb).toHaveBeenCalledWith(
9286
"Test Artist",
9387
"550e8400-e29b-41d4-a716-446655440000",
@@ -97,11 +91,12 @@ describe("createArtistPostHandler", () => {
9791
});
9892

9993
it("returns 403 when org API key lacks access to account_id", async () => {
100-
mockGetApiKeyDetails.mockResolvedValue({
101-
accountId: "org-account-id",
102-
orgId: "org-account-id",
103-
});
104-
mockCanAccessAccount.mockResolvedValue(false);
94+
mockValidateAuthContext.mockResolvedValue(
95+
NextResponse.json(
96+
{ status: "error", error: "Access denied to specified account_id" },
97+
{ status: 403 },
98+
),
99+
);
105100

106101
const request = createRequest({
107102
name: "Test Artist",
@@ -136,7 +131,14 @@ describe("createArtistPostHandler", () => {
136131
);
137132
});
138133

139-
it("returns 401 when API key is missing", async () => {
134+
it("returns 401 when auth is missing", async () => {
135+
mockValidateAuthContext.mockResolvedValue(
136+
NextResponse.json(
137+
{ status: "error", error: "Exactly one of x-api-key or Authorization must be provided" },
138+
{ status: 401 },
139+
),
140+
);
141+
140142
const request = new NextRequest("http://localhost/api/artists", {
141143
method: "POST",
142144
headers: { "Content-Type": "application/json" },
@@ -147,18 +149,7 @@ describe("createArtistPostHandler", () => {
147149
const data = await response.json();
148150

149151
expect(response.status).toBe(401);
150-
expect(data.error).toBe("x-api-key header required");
151-
});
152-
153-
it("returns 401 when API key is invalid", async () => {
154-
mockGetApiKeyDetails.mockResolvedValue(null);
155-
156-
const request = createRequest({ name: "Test Artist" });
157-
const response = await createArtistPostHandler(request);
158-
const data = await response.json();
159-
160-
expect(response.status).toBe(401);
161-
expect(data.error).toBe("Invalid API key");
152+
expect(data.error).toBe("Exactly one of x-api-key or Authorization must be provided");
162153
});
163154

164155
it("returns 400 when name is missing", async () => {

0 commit comments

Comments
 (0)