Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,23 +147,35 @@ export async function selectTableName({

**Never use `account_id` in request bodies or tool schemas.** Always derive the account ID from authentication:

- **API routes**: Use `x-api-key` header via `getApiKeyAccountId()`
- **API routes**: Use `validateAuthContext()` (supports both `x-api-key` and `Authorization: Bearer` tokens)
- **MCP tools**: Use `extra.authInfo` via `resolveAccountId()`

Both API keys and Privy access tokens resolve to an `accountId`. Never accept `account_id` as user input.

### API Routes

**CRITICAL: Always use `validateAuthContext()` for authentication.** This function supports both `x-api-key` header AND `Authorization: Bearer` token authentication. Never use `getApiKeyAccountId()` directly in route handlers - it only supports API keys and will reject Bearer tokens from the frontend.

```typescript
import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";

const authResult = await validateAuthContext(request, {
accountId: body.account_id, // Optional: for account_id override
organizationId: body.organization_id, // Optional: for org context
});

const accountIdOrError = await getApiKeyAccountId(request);
if (accountIdOrError instanceof NextResponse) {
return accountIdOrError;
if (authResult instanceof NextResponse) {
return authResult;
}
const accountId = accountIdOrError;

const { accountId, orgId, authToken } = authResult;
```

`validateAuthContext` handles:
- Both `x-api-key` and `Authorization: Bearer` authentication
- Account ID override validation (org keys can access member accounts)
- Organization access validation

### MCP Tools

```typescript
Expand All @@ -181,6 +193,7 @@ This ensures:
- Callers cannot impersonate other accounts
- Authentication is always enforced
- Account ID is derived from validated credentials
- Frontend apps using Bearer tokens work correctly

## Input Validation

Expand Down
93 changes: 56 additions & 37 deletions lib/pulse/__tests__/validateGetPulseRequest.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";

vi.mock("@/lib/auth/getApiKeyAccountId", () => ({
getApiKeyAccountId: vi.fn(),
vi.mock("@/lib/auth/validateAuthContext", () => ({
validateAuthContext: vi.fn(),
}));

vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({
validateOverrideAccountId: vi.fn(),
}));

import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId";
import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { validateGetPulseRequest } from "../validateGetPulseRequest";

/**
Expand All @@ -29,10 +24,13 @@ describe("validateGetPulseRequest", () => {
vi.clearAllMocks();
});

describe("authentication", () => {
it("returns 401 when API key is missing", async () => {
vi.mocked(getApiKeyAccountId).mockResolvedValue(
NextResponse.json({ status: "error", message: "x-api-key header required" }, { status: 401 }),
describe("authentication with x-api-key", () => {
it("returns 401 when no auth is provided", async () => {
vi.mocked(validateAuthContext).mockResolvedValue(
NextResponse.json(
{ status: "error", error: "Exactly one of x-api-key or Authorization must be provided" },
{ status: 401 },
),
);

const request = createMockRequest("https://api.example.com/api/pulse");
Expand All @@ -44,67 +42,88 @@ describe("validateGetPulseRequest", () => {
}
});

it("returns 401 when API key is invalid", async () => {
vi.mocked(getApiKeyAccountId).mockResolvedValue(
NextResponse.json({ status: "error", message: "Unauthorized" }, { status: 401 }),
);
it("returns accountId when authenticated with x-api-key", async () => {
const accountId = "account-123";
vi.mocked(validateAuthContext).mockResolvedValue({
accountId,
orgId: null,
authToken: "valid-key",
});

const request = createMockRequest("https://api.example.com/api/pulse", {
"x-api-key": "invalid-key",
"x-api-key": "valid-key",
});
const result = await validateGetPulseRequest(request);

expect(result).toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) {
expect(result.status).toBe(401);
}
expect(result).not.toBeInstanceOf(NextResponse);
expect(result).toEqual({ accountId });
});
});

describe("successful validation", () => {
it("returns accountId when authenticated without account_id param", async () => {
const accountId = "account-123";
vi.mocked(getApiKeyAccountId).mockResolvedValue(accountId);
describe("authentication with Bearer token", () => {
it("returns accountId when authenticated with Bearer token", async () => {
const accountId = "account-456";
vi.mocked(validateAuthContext).mockResolvedValue({
accountId,
orgId: null,
authToken: "bearer-token",
});

const request = createMockRequest("https://api.example.com/api/pulse", {
"x-api-key": "valid-key",
Authorization: "Bearer bearer-token",
});
const result = await validateGetPulseRequest(request);

expect(result).not.toBeInstanceOf(NextResponse);
expect(result).toEqual({ accountId });
});

it("returns 401 when Bearer token is invalid", async () => {
vi.mocked(validateAuthContext).mockResolvedValue(
NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }),
);

const request = createMockRequest("https://api.example.com/api/pulse", {
Authorization: "Bearer invalid-token",
});
const result = await validateGetPulseRequest(request);

expect(result).toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) {
expect(result.status).toBe(401);
}
});
});

describe("account_id override", () => {
it("returns overridden accountId when authorized", async () => {
const orgAccountId = "org-123";
it("passes account_id to validateAuthContext when provided", async () => {
const accountId = "account-123";
const targetAccountId = "11111111-1111-4111-a111-111111111111";

vi.mocked(getApiKeyAccountId).mockResolvedValue(orgAccountId);
vi.mocked(validateOverrideAccountId).mockResolvedValue({ accountId: targetAccountId });
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: targetAccountId,
orgId: null,
authToken: "valid-key",
});

const request = createMockRequest(
`https://api.example.com/api/pulse?account_id=${targetAccountId}`,
{ "x-api-key": "org-api-key" },
);
const result = await validateGetPulseRequest(request);

expect(validateOverrideAccountId).toHaveBeenCalledWith({
apiKey: "org-api-key",
targetAccountId,
expect(validateAuthContext).toHaveBeenCalledWith(request, {
accountId: targetAccountId,
});
expect(result).toEqual({ accountId: targetAccountId });
});

it("returns 403 when not authorized for account_id", async () => {
const orgAccountId = "org-123";
const targetAccountId = "11111111-1111-4111-a111-111111111111";

vi.mocked(getApiKeyAccountId).mockResolvedValue(orgAccountId);
vi.mocked(validateOverrideAccountId).mockResolvedValue(
vi.mocked(validateAuthContext).mockResolvedValue(
NextResponse.json(
{ status: "error", message: "Access denied to specified accountId" },
{ status: "error", error: "Access denied to specified account_id" },
{ status: 403 },
),
);
Expand Down
111 changes: 65 additions & 46 deletions lib/pulse/__tests__/validateUpdatePulseRequest.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";

vi.mock("@/lib/auth/getApiKeyAccountId", () => ({
getApiKeyAccountId: vi.fn(),
}));

vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({
validateOverrideAccountId: vi.fn(),
vi.mock("@/lib/auth/validateAuthContext", () => ({
validateAuthContext: vi.fn(),
}));

vi.mock("@/lib/networking/safeParseJson", () => ({
safeParseJson: vi.fn(),
}));

import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId";
import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { safeParseJson } from "@/lib/networking/safeParseJson";
import { validateUpdatePulseRequest } from "../validateUpdatePulseRequest";

Expand All @@ -34,26 +29,8 @@ describe("validateUpdatePulseRequest", () => {
vi.clearAllMocks();
});

describe("authentication", () => {
it("returns 401 when API key is missing", async () => {
vi.mocked(getApiKeyAccountId).mockResolvedValue(
NextResponse.json({ status: "error", message: "x-api-key header required" }, { status: 401 }),
);

const request = createMockRequest("https://api.example.com/api/pulse");
const result = await validateUpdatePulseRequest(request);

expect(result).toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) {
expect(result.status).toBe(401);
}
});
});

describe("body validation", () => {
it("returns 400 when active field is missing", async () => {
const accountId = "account-123";
vi.mocked(getApiKeyAccountId).mockResolvedValue(accountId);
vi.mocked(safeParseJson).mockResolvedValue({});

const request = createMockRequest("https://api.example.com/api/pulse", {
Expand All @@ -68,8 +45,6 @@ describe("validateUpdatePulseRequest", () => {
});

it("returns 400 when active is not a boolean", async () => {
const accountId = "account-123";
vi.mocked(getApiKeyAccountId).mockResolvedValue(accountId);
vi.mocked(safeParseJson).mockResolvedValue({ active: "true" });

const request = createMockRequest("https://api.example.com/api/pulse", {
Expand All @@ -84,11 +59,33 @@ describe("validateUpdatePulseRequest", () => {
});
});

describe("successful validation", () => {
it("returns accountId and active when valid", async () => {
describe("authentication with x-api-key", () => {
it("returns 401 when no auth is provided", async () => {
vi.mocked(safeParseJson).mockResolvedValue({ active: true });
vi.mocked(validateAuthContext).mockResolvedValue(
NextResponse.json(
{ status: "error", error: "Exactly one of x-api-key or Authorization must be provided" },
{ status: 401 },
),
);

const request = createMockRequest("https://api.example.com/api/pulse");
const result = await validateUpdatePulseRequest(request);

expect(result).toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) {
expect(result.status).toBe(401);
}
});

it("returns accountId and active when authenticated with x-api-key", async () => {
const accountId = "account-123";
vi.mocked(getApiKeyAccountId).mockResolvedValue(accountId);
vi.mocked(safeParseJson).mockResolvedValue({ active: true });
vi.mocked(validateAuthContext).mockResolvedValue({
accountId,
orgId: null,
authToken: "valid-key",
});

const request = createMockRequest("https://api.example.com/api/pulse", {
"x-api-key": "valid-key",
Expand All @@ -98,52 +95,74 @@ describe("validateUpdatePulseRequest", () => {
expect(result).not.toBeInstanceOf(NextResponse);
expect(result).toEqual({ accountId, active: true });
});
});

it("returns active: false when specified", async () => {
const accountId = "account-123";
vi.mocked(getApiKeyAccountId).mockResolvedValue(accountId);
describe("authentication with Bearer token", () => {
it("returns accountId and active when authenticated with Bearer token", async () => {
const accountId = "account-456";
vi.mocked(safeParseJson).mockResolvedValue({ active: false });
vi.mocked(validateAuthContext).mockResolvedValue({
accountId,
orgId: null,
authToken: "bearer-token",
});

const request = createMockRequest("https://api.example.com/api/pulse", {
"x-api-key": "valid-key",
Authorization: "Bearer bearer-token",
});
const result = await validateUpdatePulseRequest(request);

expect(result).not.toBeInstanceOf(NextResponse);
expect(result).toEqual({ accountId, active: false });
});

it("returns 401 when Bearer token is invalid", async () => {
vi.mocked(safeParseJson).mockResolvedValue({ active: true });
vi.mocked(validateAuthContext).mockResolvedValue(
NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }),
);

const request = createMockRequest("https://api.example.com/api/pulse", {
Authorization: "Bearer invalid-token",
});
const result = await validateUpdatePulseRequest(request);

expect(result).toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) {
expect(result.status).toBe(401);
}
});
});

describe("account_id override", () => {
it("returns overridden accountId when authorized", async () => {
const orgAccountId = "org-123";
it("passes account_id to validateAuthContext when provided in body", async () => {
const targetAccountId = "11111111-1111-4111-a111-111111111111";

vi.mocked(getApiKeyAccountId).mockResolvedValue(orgAccountId);
vi.mocked(safeParseJson).mockResolvedValue({ active: true, account_id: targetAccountId });
vi.mocked(validateOverrideAccountId).mockResolvedValue({ accountId: targetAccountId });
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: targetAccountId,
orgId: null,
authToken: "valid-key",
});

const request = createMockRequest("https://api.example.com/api/pulse", {
"x-api-key": "org-api-key",
});
const result = await validateUpdatePulseRequest(request);

expect(validateOverrideAccountId).toHaveBeenCalledWith({
apiKey: "org-api-key",
targetAccountId,
expect(validateAuthContext).toHaveBeenCalledWith(request, {
accountId: targetAccountId,
});
expect(result).toEqual({ accountId: targetAccountId, active: true });
});

it("returns 403 when not authorized for account_id", async () => {
const orgAccountId = "org-123";
const targetAccountId = "11111111-1111-4111-a111-111111111111";

vi.mocked(getApiKeyAccountId).mockResolvedValue(orgAccountId);
vi.mocked(safeParseJson).mockResolvedValue({ active: true, account_id: targetAccountId });
vi.mocked(validateOverrideAccountId).mockResolvedValue(
vi.mocked(validateAuthContext).mockResolvedValue(
NextResponse.json(
{ status: "error", message: "Access denied to specified accountId" },
{ status: "error", error: "Access denied to specified account_id" },
{ status: 403 },
),
);
Expand Down
Loading