Skip to content

Commit dac4fb7

Browse files
sweetmantechclaude
andauthored
feat: add RECOUP_ORG_ID constant for accountId override (#109)
* feat: add RECOUP_ORG_ID constant for admin organization access Add the Recoup admin organization UUID constant to support the accountId override feature. API keys from this organization have universal access and can specify any accountId when creating chats. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add getApiKeyDetails function for org context resolution Adds a new authentication utility that extracts both accountId and orgId from an API key. This is needed for the accountId override feature where org API keys can specify a target accountId. - Returns accountId from the API key's account field - Returns orgId if the account is an organization (exists in account_organization_ids) - Returns null orgId for personal API keys - Includes 7 unit tests covering all scenarios Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add canAccessAccount validation function for org access control Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: add Supabase database operations architecture to CLAUDE.md Document that all Supabase calls must be in lib/supabase/[table_name]/[function].ts - Add directory structure example - Add naming conventions (select, insert, update, delete, get) - Add code pattern template Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add optional accountId to createChat validation schema Add accountId field to POST /api/chats validation schema to enable account override for organization API keys. Includes 9 unit tests for the validation logic. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add accountId override support to createChatHandler Allow org API keys to create chat rooms for accounts within their org. - When accountId provided, validates access via getApiKeyDetails + canAccessAccount - Returns 403 if access denied, 500 if API key validation fails - Recoup admin org has universal access to all accounts - Includes 6 unit tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: strengthen Supabase architecture rule in CLAUDE.md - Add CRITICAL marker: NEVER import serverClient outside lib/supabase/ - Add 3-step process for database access in domain code - Add WRONG vs CORRECT code examples - Make rule more prominent and actionable Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: use getAccountOrganizations in canAccessAccount Replace direct Supabase query with existing getAccountOrganizations() lib function, following the architecture pattern documented in CLAUDE.md. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: use isOrganization supabase lib in getApiKeyDetails Extracted direct Supabase call from getApiKeyDetails.ts into a new isOrganization() function in lib/supabase/account_organization_ids/. This follows the architecture pattern of using supabase lib functions instead of direct queries. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: move canAccessAccount from lib/auth to lib/organizations canAccessAccount is about organization access control, not authentication. Moving it to lib/organizations for better code organization. - Moved lib/auth/canAccessAccount.ts to lib/organizations/canAccessAccount.ts - Moved tests to lib/organizations/__tests__/canAccessAccount.test.ts - Updated imports in createChatHandler.ts and its tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: move getApiKeyDetails from lib/auth to lib/keys Move getApiKeyDetails to lib/keys since it's about API key operations, not authentication. Updated imports in createChatHandler and its tests. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: extract validateOverrideAccountId and remove isOrganization - Create lib/accounts/validateOverrideAccountId.ts for SRP - Update createChatHandler to use validateOverrideAccountId - Replace isOrganization with getAccountOrganizations in getApiKeyDetails - Extend getAccountOrganizations to support organizationId-only queries - Delete isOrganization.ts and its tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent b6402e7 commit dac4fb7

File tree

13 files changed

+951
-13
lines changed

13 files changed

+951
-13
lines changed

CLAUDE.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,90 @@ pnpm format:check # Check formatting
5151
- `lib/trigger/` - Trigger.dev task triggers
5252
- `lib/x402/` - Payment middleware utilities
5353

54+
## Supabase Database Operations
55+
56+
**CRITICAL: NEVER import `@/lib/supabase/serverClient` outside of `lib/supabase/` directory.**
57+
58+
All Supabase database calls **must** be in `lib/supabase/[table_name]/[function].ts`.
59+
60+
If you need database access in `lib/auth/`, `lib/chats/`, or any other domain folder:
61+
1. **First** check if a function already exists in `lib/supabase/[table_name]/`
62+
2. If not, **create** a new function in `lib/supabase/[table_name]/` first
63+
3. **Then** import and use that function in your domain code
64+
65+
**WRONG** - Direct Supabase call in domain code:
66+
```typescript
67+
// lib/auth/someFunction.ts
68+
import supabase from "@/lib/supabase/serverClient"; // NEVER DO THIS
69+
const { data } = await supabase.from("accounts").select("*");
70+
```
71+
72+
**CORRECT** - Import from supabase lib:
73+
```typescript
74+
// lib/auth/someFunction.ts
75+
import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts";
76+
const accounts = await selectAccounts();
77+
```
78+
79+
### Directory Structure
80+
81+
```
82+
lib/supabase/
83+
├── serverClient.ts # Supabase client instance
84+
├── accounts/
85+
│ ├── selectAccounts.ts
86+
│ ├── insertAccount.ts
87+
│ └── updateAccount.ts
88+
├── account_api_keys/
89+
│ ├── selectAccountApiKeys.ts
90+
│ ├── insertApiKey.ts
91+
│ └── deleteApiKey.ts
92+
├── account_organization_ids/
93+
│ ├── getAccountOrganizations.ts
94+
│ └── addAccountToOrganization.ts
95+
└── [table_name]/
96+
└── [action][TableName].ts
97+
```
98+
99+
### Naming Conventions
100+
101+
- `select[TableName].ts` - Basic SELECT queries
102+
- `insert[TableName].ts` - INSERT queries
103+
- `update[TableName].ts` - UPDATE queries
104+
- `delete[TableName].ts` - DELETE queries
105+
- `get[Descriptive].ts` - Complex queries with joins
106+
107+
### Pattern
108+
109+
```typescript
110+
import supabase from "@/lib/supabase/serverClient";
111+
import type { Tables } from "@/types/database.types";
112+
113+
/**
114+
* Select rows from table_name with optional filters.
115+
*/
116+
export async function selectTableName({
117+
filter,
118+
}: {
119+
filter?: string;
120+
} = {}): Promise<Tables<"table_name">[] | null> {
121+
let query = supabase.from("table_name").select("*");
122+
123+
if (filter) {
124+
query = query.eq("column", filter);
125+
}
126+
127+
const { data, error } = await query;
128+
129+
if (error) {
130+
console.error("Error fetching table_name:", error);
131+
return null;
132+
}
133+
134+
return data || [];
135+
}
136+
```
137+
54138
## Code Principles
55139

56140
- **SRP (Single Responsibility Principle)**: One exported function per file. Each file should do one thing well.
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { NextResponse } from "next/server";
3+
import { validateOverrideAccountId } from "../validateOverrideAccountId";
4+
5+
import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails";
6+
import { canAccessAccount } from "@/lib/organizations/canAccessAccount";
7+
8+
vi.mock("@/lib/networking/getCorsHeaders", () => ({
9+
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
10+
}));
11+
12+
vi.mock("@/lib/keys/getApiKeyDetails", () => ({
13+
getApiKeyDetails: vi.fn(),
14+
}));
15+
16+
vi.mock("@/lib/organizations/canAccessAccount", () => ({
17+
canAccessAccount: vi.fn(),
18+
}));
19+
20+
describe("validateOverrideAccountId", () => {
21+
beforeEach(() => {
22+
vi.clearAllMocks();
23+
});
24+
25+
describe("successful validation", () => {
26+
it("returns accountId when org has access to target account", async () => {
27+
const targetAccountId = "target-account-123";
28+
const orgId = "org-456";
29+
30+
vi.mocked(getApiKeyDetails).mockResolvedValue({
31+
accountId: orgId,
32+
orgId: orgId,
33+
});
34+
vi.mocked(canAccessAccount).mockResolvedValue(true);
35+
36+
const result = await validateOverrideAccountId({
37+
apiKey: "valid_api_key",
38+
targetAccountId,
39+
});
40+
41+
expect(result).toEqual({ accountId: targetAccountId });
42+
expect(getApiKeyDetails).toHaveBeenCalledWith("valid_api_key");
43+
expect(canAccessAccount).toHaveBeenCalledWith({
44+
orgId,
45+
targetAccountId,
46+
});
47+
});
48+
});
49+
50+
describe("missing API key", () => {
51+
it("returns 500 error when apiKey is null", async () => {
52+
const result = await validateOverrideAccountId({
53+
apiKey: null,
54+
targetAccountId: "target-123",
55+
});
56+
57+
expect(result).toBeInstanceOf(NextResponse);
58+
const response = result as NextResponse;
59+
expect(response.status).toBe(500);
60+
61+
const body = await response.json();
62+
expect(body).toEqual({
63+
status: "error",
64+
message: "Failed to validate API key",
65+
});
66+
});
67+
});
68+
69+
describe("invalid API key", () => {
70+
it("returns 500 error when getApiKeyDetails returns null", async () => {
71+
vi.mocked(getApiKeyDetails).mockResolvedValue(null);
72+
73+
const result = await validateOverrideAccountId({
74+
apiKey: "invalid_key",
75+
targetAccountId: "target-123",
76+
});
77+
78+
expect(result).toBeInstanceOf(NextResponse);
79+
const response = result as NextResponse;
80+
expect(response.status).toBe(500);
81+
82+
const body = await response.json();
83+
expect(body).toEqual({
84+
status: "error",
85+
message: "Failed to validate API key",
86+
});
87+
});
88+
});
89+
90+
describe("access denied", () => {
91+
it("returns 403 error when org does not have access to target account", async () => {
92+
vi.mocked(getApiKeyDetails).mockResolvedValue({
93+
accountId: "org-123",
94+
orgId: "org-123",
95+
});
96+
vi.mocked(canAccessAccount).mockResolvedValue(false);
97+
98+
const result = await validateOverrideAccountId({
99+
apiKey: "valid_key",
100+
targetAccountId: "unauthorized-account",
101+
});
102+
103+
expect(result).toBeInstanceOf(NextResponse);
104+
const response = result as NextResponse;
105+
expect(response.status).toBe(403);
106+
107+
const body = await response.json();
108+
expect(body).toEqual({
109+
status: "error",
110+
message: "Access denied to specified accountId",
111+
});
112+
});
113+
114+
it("returns 403 error when API key is personal (no orgId)", async () => {
115+
vi.mocked(getApiKeyDetails).mockResolvedValue({
116+
accountId: "personal-account",
117+
orgId: null,
118+
});
119+
vi.mocked(canAccessAccount).mockResolvedValue(false);
120+
121+
const result = await validateOverrideAccountId({
122+
apiKey: "personal_key",
123+
targetAccountId: "some-account",
124+
});
125+
126+
expect(result).toBeInstanceOf(NextResponse);
127+
const response = result as NextResponse;
128+
expect(response.status).toBe(403);
129+
});
130+
});
131+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { NextResponse } from "next/server";
2+
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
3+
import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails";
4+
import { canAccessAccount } from "@/lib/organizations/canAccessAccount";
5+
6+
export type ValidateOverrideAccountIdParams = {
7+
apiKey: string | null;
8+
targetAccountId: string;
9+
};
10+
11+
export type ValidateOverrideAccountIdResult = {
12+
accountId: string;
13+
};
14+
15+
/**
16+
* Validates that an API key has permission to override to a target accountId.
17+
*
18+
* Used when an org API key wants to create resources on behalf of another account.
19+
* Checks that the API key belongs to an org with access to the target account.
20+
*
21+
* @param params.apiKey - The x-api-key header value
22+
* @param params.targetAccountId - The accountId to override to
23+
* @param root0
24+
* @param root0.apiKey
25+
* @param root0.targetAccountId
26+
* @returns The validated accountId or a NextResponse error
27+
*/
28+
export async function validateOverrideAccountId({
29+
apiKey,
30+
targetAccountId,
31+
}: ValidateOverrideAccountIdParams): Promise<NextResponse | ValidateOverrideAccountIdResult> {
32+
if (!apiKey) {
33+
return NextResponse.json(
34+
{
35+
status: "error",
36+
message: "Failed to validate API key",
37+
},
38+
{
39+
status: 500,
40+
headers: getCorsHeaders(),
41+
},
42+
);
43+
}
44+
45+
const keyDetails = await getApiKeyDetails(apiKey);
46+
if (!keyDetails) {
47+
return NextResponse.json(
48+
{
49+
status: "error",
50+
message: "Failed to validate API key",
51+
},
52+
{
53+
status: 500,
54+
headers: getCorsHeaders(),
55+
},
56+
);
57+
}
58+
59+
const hasAccess = await canAccessAccount({
60+
orgId: keyDetails.orgId,
61+
targetAccountId,
62+
});
63+
64+
if (!hasAccess) {
65+
return NextResponse.json(
66+
{
67+
status: "error",
68+
message: "Access denied to specified accountId",
69+
},
70+
{
71+
status: 403,
72+
headers: getCorsHeaders(),
73+
},
74+
);
75+
}
76+
77+
return { accountId: targetAccountId };
78+
}

0 commit comments

Comments
 (0)