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
84 changes: 84 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,90 @@ pnpm format:check # Check formatting
- `lib/trigger/` - Trigger.dev task triggers
- `lib/x402/` - Payment middleware utilities

## Supabase Database Operations

**CRITICAL: NEVER import `@/lib/supabase/serverClient` outside of `lib/supabase/` directory.**

All Supabase database calls **must** be in `lib/supabase/[table_name]/[function].ts`.

If you need database access in `lib/auth/`, `lib/chats/`, or any other domain folder:
1. **First** check if a function already exists in `lib/supabase/[table_name]/`
2. If not, **create** a new function in `lib/supabase/[table_name]/` first
3. **Then** import and use that function in your domain code

❌ **WRONG** - Direct Supabase call in domain code:
```typescript
// lib/auth/someFunction.ts
import supabase from "@/lib/supabase/serverClient"; // NEVER DO THIS
const { data } = await supabase.from("accounts").select("*");
```

✅ **CORRECT** - Import from supabase lib:
```typescript
// lib/auth/someFunction.ts
import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts";
const accounts = await selectAccounts();
```

### Directory Structure

```
lib/supabase/
├── serverClient.ts # Supabase client instance
├── accounts/
│ ├── selectAccounts.ts
│ ├── insertAccount.ts
│ └── updateAccount.ts
├── account_api_keys/
│ ├── selectAccountApiKeys.ts
│ ├── insertApiKey.ts
│ └── deleteApiKey.ts
├── account_organization_ids/
│ ├── getAccountOrganizations.ts
│ └── addAccountToOrganization.ts
└── [table_name]/
└── [action][TableName].ts
```

### Naming Conventions

- `select[TableName].ts` - Basic SELECT queries
- `insert[TableName].ts` - INSERT queries
- `update[TableName].ts` - UPDATE queries
- `delete[TableName].ts` - DELETE queries
- `get[Descriptive].ts` - Complex queries with joins

### Pattern

```typescript
import supabase from "@/lib/supabase/serverClient";
import type { Tables } from "@/types/database.types";

/**
* Select rows from table_name with optional filters.
*/
export async function selectTableName({
filter,
}: {
filter?: string;
} = {}): Promise<Tables<"table_name">[] | null> {
let query = supabase.from("table_name").select("*");

if (filter) {
query = query.eq("column", filter);
}

const { data, error } = await query;

if (error) {
console.error("Error fetching table_name:", error);
return null;
}

return data || [];
}
```

## Code Principles

- **SRP (Single Responsibility Principle)**: One exported function per file. Each file should do one thing well.
Expand Down
131 changes: 131 additions & 0 deletions lib/accounts/__tests__/validateOverrideAccountId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextResponse } from "next/server";
import { validateOverrideAccountId } from "../validateOverrideAccountId";

import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails";
import { canAccessAccount } from "@/lib/organizations/canAccessAccount";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));

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

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

describe("validateOverrideAccountId", () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe("successful validation", () => {
it("returns accountId when org has access to target account", async () => {
const targetAccountId = "target-account-123";
const orgId = "org-456";

vi.mocked(getApiKeyDetails).mockResolvedValue({
accountId: orgId,
orgId: orgId,
});
vi.mocked(canAccessAccount).mockResolvedValue(true);

const result = await validateOverrideAccountId({
apiKey: "valid_api_key",
targetAccountId,
});

expect(result).toEqual({ accountId: targetAccountId });
expect(getApiKeyDetails).toHaveBeenCalledWith("valid_api_key");
expect(canAccessAccount).toHaveBeenCalledWith({
orgId,
targetAccountId,
});
});
});

describe("missing API key", () => {
it("returns 500 error when apiKey is null", async () => {
const result = await validateOverrideAccountId({
apiKey: null,
targetAccountId: "target-123",
});

expect(result).toBeInstanceOf(NextResponse);
const response = result as NextResponse;
expect(response.status).toBe(500);

const body = await response.json();
expect(body).toEqual({
status: "error",
message: "Failed to validate API key",
});
});
});

describe("invalid API key", () => {
it("returns 500 error when getApiKeyDetails returns null", async () => {
vi.mocked(getApiKeyDetails).mockResolvedValue(null);

const result = await validateOverrideAccountId({
apiKey: "invalid_key",
targetAccountId: "target-123",
});

expect(result).toBeInstanceOf(NextResponse);
const response = result as NextResponse;
expect(response.status).toBe(500);

const body = await response.json();
expect(body).toEqual({
status: "error",
message: "Failed to validate API key",
});
});
});

describe("access denied", () => {
it("returns 403 error when org does not have access to target account", async () => {
vi.mocked(getApiKeyDetails).mockResolvedValue({
accountId: "org-123",
orgId: "org-123",
});
vi.mocked(canAccessAccount).mockResolvedValue(false);

const result = await validateOverrideAccountId({
apiKey: "valid_key",
targetAccountId: "unauthorized-account",
});

expect(result).toBeInstanceOf(NextResponse);
const response = result as NextResponse;
expect(response.status).toBe(403);

const body = await response.json();
expect(body).toEqual({
status: "error",
message: "Access denied to specified accountId",
});
});

it("returns 403 error when API key is personal (no orgId)", async () => {
vi.mocked(getApiKeyDetails).mockResolvedValue({
accountId: "personal-account",
orgId: null,
});
vi.mocked(canAccessAccount).mockResolvedValue(false);

const result = await validateOverrideAccountId({
apiKey: "personal_key",
targetAccountId: "some-account",
});

expect(result).toBeInstanceOf(NextResponse);
const response = result as NextResponse;
expect(response.status).toBe(403);
});
});
});
78 changes: 78 additions & 0 deletions lib/accounts/validateOverrideAccountId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails";
import { canAccessAccount } from "@/lib/organizations/canAccessAccount";

export type ValidateOverrideAccountIdParams = {
apiKey: string | null;
targetAccountId: string;
};

export type ValidateOverrideAccountIdResult = {
accountId: string;
};

/**
* Validates that an API key has permission to override to a target accountId.
*
* Used when an org API key wants to create resources on behalf of another account.
* Checks that the API key belongs to an org with access to the target account.
*
* @param params.apiKey - The x-api-key header value
* @param params.targetAccountId - The accountId to override to
* @param root0
* @param root0.apiKey
* @param root0.targetAccountId
* @returns The validated accountId or a NextResponse error
*/
export async function validateOverrideAccountId({
apiKey,
targetAccountId,
}: ValidateOverrideAccountIdParams): Promise<NextResponse | ValidateOverrideAccountIdResult> {
if (!apiKey) {
return NextResponse.json(
{
status: "error",
message: "Failed to validate API key",
},
{
status: 500,
headers: getCorsHeaders(),
},
);
}

const keyDetails = await getApiKeyDetails(apiKey);
if (!keyDetails) {
return NextResponse.json(
{
status: "error",
message: "Failed to validate API key",
},
{
status: 500,
headers: getCorsHeaders(),
},
);
}

const hasAccess = await canAccessAccount({
orgId: keyDetails.orgId,
targetAccountId,
});

if (!hasAccess) {
return NextResponse.json(
{
status: "error",
message: "Access denied to specified accountId",
},
{
status: 403,
headers: getCorsHeaders(),
},
);
}

return { accountId: targetAccountId };
}
Loading