Optimized agents rules subfolders, dependencies updates (#355)
* Update AGENTS.md and CLAUDE.md for improved clarity and structure * Added MCP Server * Added missing triggers to tables that should have used them * Updated all dependencies * Fixed rare bug in React present in the Admin layout which prevents navigating to pages (sometimes...)
This commit is contained in:
committed by
GitHub
parent
9fae142f2d
commit
533dfba5b9
105
packages/analytics/AGENTS.md
Normal file
105
packages/analytics/AGENTS.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# @kit/analytics Package
|
||||
|
||||
Analytics package providing a unified interface for tracking events, page views, and user identification across multiple analytics providers.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **AnalyticsManager**: Central manager orchestrating multiple analytics providers
|
||||
- **AnalyticsService**: Interface defining analytics operations (track, identify, pageView)
|
||||
- **Provider System**: Pluggable providers (currently includes NullAnalyticsService)
|
||||
- **Client/Server Split**: Separate entry points for client and server-side usage
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Import
|
||||
|
||||
```typescript
|
||||
// Client-side
|
||||
import { analytics } from '@kit/analytics';
|
||||
|
||||
// Server-side
|
||||
import { analytics } from '@kit/analytics/server';
|
||||
```
|
||||
|
||||
### Core Methods
|
||||
|
||||
```typescript
|
||||
// Track events
|
||||
await analytics.trackEvent('button_clicked', {
|
||||
button_id: 'signup',
|
||||
page: 'homepage'
|
||||
});
|
||||
|
||||
// Track page views
|
||||
await analytics.trackPageView('/dashboard');
|
||||
|
||||
// Identify users
|
||||
await analytics.identify('user123', {
|
||||
email: 'user@example.com',
|
||||
plan: 'premium'
|
||||
});
|
||||
```
|
||||
|
||||
Page views and user identification are handled by the plugin by default.
|
||||
|
||||
## Creating Custom Providers
|
||||
|
||||
Implement the `AnalyticsService` interface:
|
||||
|
||||
```typescript
|
||||
import { AnalyticsService } from '@kit/analytics';
|
||||
|
||||
class CustomAnalyticsService implements AnalyticsService {
|
||||
async initialize(): Promise<void> {
|
||||
// Initialize your analytics service
|
||||
}
|
||||
|
||||
async trackEvent(name: string, properties?: Record<string, string | string[]>): Promise<void> {
|
||||
// Track event implementation
|
||||
}
|
||||
|
||||
async trackPageView(path: string): Promise<void> {
|
||||
// Track page view implementation
|
||||
}
|
||||
|
||||
async identify(userId: string, traits?: Record<string, string>): Promise<void> {
|
||||
// Identify user implementation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Default Behavior
|
||||
|
||||
- Uses `NullAnalyticsService` when no providers are active
|
||||
- All methods return Promises that resolve to arrays of provider results
|
||||
- Console debug logging when no active services or using null service
|
||||
- Graceful error handling with console warnings for missing providers
|
||||
|
||||
## Server-Side Analytics
|
||||
|
||||
When using PostHog, you can track events server-side for better reliability and privacy:
|
||||
|
||||
```typescript
|
||||
import { analytics } from '@kit/analytics/server';
|
||||
|
||||
// Server-side event tracking (e.g., in API routes)
|
||||
export async function POST(request: Request) {
|
||||
// ... handle request
|
||||
|
||||
// Track server-side events
|
||||
await analytics.trackEvent('api_call', {
|
||||
endpoint: '/api/users',
|
||||
method: 'POST',
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
// Track user registration server-side
|
||||
await analytics.identify(user.id, {
|
||||
email: user.email,
|
||||
created_at: user.created_at,
|
||||
plan: user.plan,
|
||||
});
|
||||
```
|
||||
105
packages/analytics/CLAUDE.md
Normal file
105
packages/analytics/CLAUDE.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# @kit/analytics Package
|
||||
|
||||
Analytics package providing a unified interface for tracking events, page views, and user identification across multiple analytics providers.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **AnalyticsManager**: Central manager orchestrating multiple analytics providers
|
||||
- **AnalyticsService**: Interface defining analytics operations (track, identify, pageView)
|
||||
- **Provider System**: Pluggable providers (currently includes NullAnalyticsService)
|
||||
- **Client/Server Split**: Separate entry points for client and server-side usage
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Import
|
||||
|
||||
```typescript
|
||||
// Client-side
|
||||
import { analytics } from '@kit/analytics';
|
||||
|
||||
// Server-side
|
||||
import { analytics } from '@kit/analytics/server';
|
||||
```
|
||||
|
||||
### Core Methods
|
||||
|
||||
```typescript
|
||||
// Track events
|
||||
await analytics.trackEvent('button_clicked', {
|
||||
button_id: 'signup',
|
||||
page: 'homepage'
|
||||
});
|
||||
|
||||
// Track page views
|
||||
await analytics.trackPageView('/dashboard');
|
||||
|
||||
// Identify users
|
||||
await analytics.identify('user123', {
|
||||
email: 'user@example.com',
|
||||
plan: 'premium'
|
||||
});
|
||||
```
|
||||
|
||||
Page views and user identification are handled by the plugin by default.
|
||||
|
||||
## Creating Custom Providers
|
||||
|
||||
Implement the `AnalyticsService` interface:
|
||||
|
||||
```typescript
|
||||
import { AnalyticsService } from '@kit/analytics';
|
||||
|
||||
class CustomAnalyticsService implements AnalyticsService {
|
||||
async initialize(): Promise<void> {
|
||||
// Initialize your analytics service
|
||||
}
|
||||
|
||||
async trackEvent(name: string, properties?: Record<string, string | string[]>): Promise<void> {
|
||||
// Track event implementation
|
||||
}
|
||||
|
||||
async trackPageView(path: string): Promise<void> {
|
||||
// Track page view implementation
|
||||
}
|
||||
|
||||
async identify(userId: string, traits?: Record<string, string>): Promise<void> {
|
||||
// Identify user implementation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Default Behavior
|
||||
|
||||
- Uses `NullAnalyticsService` when no providers are active
|
||||
- All methods return Promises that resolve to arrays of provider results
|
||||
- Console debug logging when no active services or using null service
|
||||
- Graceful error handling with console warnings for missing providers
|
||||
|
||||
## Server-Side Analytics
|
||||
|
||||
When using PostHog, you can track events server-side for better reliability and privacy:
|
||||
|
||||
```typescript
|
||||
import { analytics } from '@kit/analytics/server';
|
||||
|
||||
// Server-side event tracking (e.g., in API routes)
|
||||
export async function POST(request: Request) {
|
||||
// ... handle request
|
||||
|
||||
// Track server-side events
|
||||
await analytics.trackEvent('api_call', {
|
||||
endpoint: '/api/users',
|
||||
method: 'POST',
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
// Track user registration server-side
|
||||
await analytics.identify(user.id, {
|
||||
email: user.email,
|
||||
created_at: user.created_at,
|
||||
plan: user.plan,
|
||||
});
|
||||
```
|
||||
@@ -17,7 +17,7 @@
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/node": "^24.3.1"
|
||||
"@types/node": "^24.5.0"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"./marketing": "./src/components/marketing.tsx"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@kit/billing": "workspace:*",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/lemon-squeezy": "workspace:*",
|
||||
@@ -26,11 +26,11 @@
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@types/react": "19.1.12",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@types/react": "19.1.13",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "15.5.2",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.1",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-i18next": "^15.7.3",
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@types/react": "19.1.12",
|
||||
"next": "15.5.2",
|
||||
"@types/react": "19.1.13",
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.1",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stripe/react-stripe-js": "^4.0.0",
|
||||
"@stripe/react-stripe-js": "^4.0.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"stripe": "^18.5.0"
|
||||
},
|
||||
@@ -27,9 +27,9 @@
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react": "19.1.13",
|
||||
"date-fns": "^4.1.0",
|
||||
"next": "15.5.2",
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.1",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/wordpress": "workspace:*",
|
||||
"@types/node": "^24.3.1"
|
||||
"@types/node": "^24.5.0"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/node": "^24.5.0",
|
||||
"@types/react": "19.1.13",
|
||||
"react": "19.1.1",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/node": "^24.5.0",
|
||||
"@types/react": "19.1.13",
|
||||
"wp-types": "^4.68.1"
|
||||
},
|
||||
"typesVersions": {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/team-accounts": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"typesVersions": {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "0.5.1"
|
||||
"@react-email/components": "0.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
|
||||
289
packages/features/AGENTS.md
Normal file
289
packages/features/AGENTS.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Feature Packages Instructions
|
||||
|
||||
This file contains instructions for working with feature packages including accounts, teams, billing, auth, and notifications.
|
||||
|
||||
## Feature Package Structure
|
||||
|
||||
- `accounts/` - Personal account management
|
||||
- `admin/` - Super admin functionality
|
||||
- `auth/` - Authentication features
|
||||
- `notifications/` - Notification system
|
||||
- `team-accounts/` - Team account management
|
||||
|
||||
## Account Services
|
||||
|
||||
### Personal Accounts API
|
||||
|
||||
Located at: `packages/features/accounts/src/server/api.ts`
|
||||
|
||||
```typescript
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
|
||||
// Get account data
|
||||
const account = await api.getAccount(accountId);
|
||||
|
||||
// Get account workspace
|
||||
const workspace = await api.getAccountWorkspace();
|
||||
|
||||
// Load user accounts
|
||||
const accounts = await api.loadUserAccounts();
|
||||
|
||||
// Get subscription
|
||||
const subscription = await api.getSubscription(accountId);
|
||||
|
||||
// Get customer ID
|
||||
const customerId = await api.getCustomerId(accountId);
|
||||
```
|
||||
|
||||
### Team Accounts API
|
||||
|
||||
Located at: `packages/features/team-accounts/src/server/api.ts`
|
||||
|
||||
```typescript
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
// Get team account by slug
|
||||
const account = await api.getTeamAccount(slug);
|
||||
|
||||
// Get account workspace
|
||||
const workspace = await api.getAccountWorkspace(slug);
|
||||
|
||||
// Check permissions
|
||||
const hasPermission = await api.hasPermission({
|
||||
accountId,
|
||||
userId,
|
||||
permission: 'billing.manage'
|
||||
});
|
||||
|
||||
// Get members count
|
||||
const count = await api.getMembersCount(accountId);
|
||||
|
||||
// Get invitation
|
||||
const invitation = await api.getInvitation(adminClient, token);
|
||||
```
|
||||
|
||||
## Workspace Contexts
|
||||
|
||||
### Personal Account Context
|
||||
|
||||
Use in `apps/web/app/home/(user)` routes:
|
||||
|
||||
```tsx
|
||||
import { useUserWorkspace } from 'kit/accounts/hooks/use-user-workspace';
|
||||
|
||||
function PersonalComponent() {
|
||||
const { user, account } = useUserWorkspace();
|
||||
|
||||
// user: authenticated user data
|
||||
// account: personal account data
|
||||
|
||||
return <div>Welcome {user.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: `packages/features/accounts/src/components/user-workspace-context-provider.tsx`
|
||||
|
||||
### Team Account Context
|
||||
|
||||
Use in `apps/web/app/home/[account]` routes:
|
||||
|
||||
```tsx
|
||||
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
|
||||
|
||||
function TeamComponent() {
|
||||
const { account, user, accounts } = useTeamAccountWorkspace();
|
||||
|
||||
// account: current team account data
|
||||
// user: authenticated user data
|
||||
// accounts: all accounts user has access to
|
||||
|
||||
return <div>Team: {account.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: `packages/features/team-accounts/src/components/team-account-workspace-context-provider.tsx`
|
||||
|
||||
## Billing Services
|
||||
|
||||
### Personal Billing
|
||||
|
||||
Located at: `apps/web/app/home/(user)/billing/_lib/server/user-billing.service.ts`
|
||||
|
||||
```typescript
|
||||
// Personal billing operations
|
||||
// - Manage individual user subscriptions
|
||||
// - Handle personal account payments
|
||||
// - Process individual billing changes
|
||||
```
|
||||
|
||||
### Team Billing
|
||||
|
||||
Located at: `apps/web/app/home/[account]/billing/_lib/server/team-billing.service.ts`
|
||||
|
||||
```typescript
|
||||
// Team billing operations
|
||||
// - Manage team subscriptions
|
||||
// - Handle team payments
|
||||
// - Process team billing changes
|
||||
```
|
||||
|
||||
### Per-Seat Billing Service
|
||||
|
||||
Located at: `packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts`
|
||||
|
||||
```typescript
|
||||
import { createAccountPerSeatBillingService } from '@kit/team-accounts/billing';
|
||||
|
||||
const billingService = createAccountPerSeatBillingService(client);
|
||||
|
||||
// Increase seats when adding team members
|
||||
await billingService.increaseSeats(accountId);
|
||||
|
||||
// Decrease seats when removing team members
|
||||
await billingService.decreaseSeats(accountId);
|
||||
|
||||
// Get per-seat subscription item
|
||||
const subscription = await billingService.getPerSeatSubscriptionItem(accountId);
|
||||
```
|
||||
|
||||
## Authentication Features
|
||||
|
||||
### OTP for Sensitive Operations
|
||||
|
||||
Use one-time tokens from `packages/otp/src/api/index.ts`:
|
||||
|
||||
```tsx
|
||||
import { VerifyOtpForm } from '@kit/otp/components';
|
||||
|
||||
<VerifyOtpForm
|
||||
purpose="account-deletion"
|
||||
email={user.email}
|
||||
onSuccess={(otp) => {
|
||||
// Proceed with verified operation
|
||||
handleSensitiveOperation(otp);
|
||||
}}
|
||||
CancelButton={<Button variant="outline">Cancel</Button>}
|
||||
/>
|
||||
```
|
||||
|
||||
## Admin Features
|
||||
|
||||
### Super Admin Protection
|
||||
|
||||
For admin routes, use `AdminGuard`:
|
||||
|
||||
```tsx
|
||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||
|
||||
function AdminPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Admin Dashboard</h1>
|
||||
{/* Admin content */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap the page component
|
||||
export default AdminGuard(AdminPage);
|
||||
```
|
||||
|
||||
### Admin Service
|
||||
|
||||
Located at: `packages/features/admin/src/lib/server/services/admin.service.ts`
|
||||
|
||||
```typescript
|
||||
// Admin service operations
|
||||
// - Manage all accounts
|
||||
// - Handle admin-level operations
|
||||
// - Access system-wide data
|
||||
```
|
||||
|
||||
### Checking Admin Status
|
||||
|
||||
```typescript
|
||||
import { isSuperAdmin } from '@kit/admin';
|
||||
|
||||
function criticalAdminFeature() {
|
||||
const isAdmin = await isSuperAdmin(client);
|
||||
|
||||
if (!isAdmin) {
|
||||
throw new Error('Access denied: Admin privileges required');
|
||||
}
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling & Logging
|
||||
|
||||
### Structured Logging
|
||||
|
||||
Use logger from `packages/shared/src/logger/logger.ts`:
|
||||
|
||||
```typescript
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
async function featureOperation() {
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
name: 'feature-operation',
|
||||
userId: user.id,
|
||||
accountId: account.id
|
||||
};
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Starting feature operation');
|
||||
|
||||
// Perform operation
|
||||
const result = await performOperation();
|
||||
|
||||
logger.info({ ...ctx, result }, 'Feature operation completed');
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'Feature operation failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Permission Patterns
|
||||
|
||||
### Team Permissions
|
||||
|
||||
```typescript
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
// Check if user has specific permission on account
|
||||
const canManageBilling = await api.hasPermission({
|
||||
accountId,
|
||||
userId,
|
||||
permission: 'billing.manage'
|
||||
});
|
||||
|
||||
if (!canManageBilling) {
|
||||
throw new Error('Insufficient permissions');
|
||||
}
|
||||
```
|
||||
|
||||
### Account Ownership
|
||||
|
||||
```typescript
|
||||
// Check if user is account owner (works for both personal and team accounts)
|
||||
const isOwner = await client.rpc('is_account_owner', {
|
||||
account_id: accountId
|
||||
});
|
||||
|
||||
if (!isOwner) {
|
||||
throw new Error('Only account owners can perform this action');
|
||||
}
|
||||
```
|
||||
289
packages/features/CLAUDE.md
Normal file
289
packages/features/CLAUDE.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Feature Packages Instructions
|
||||
|
||||
This file contains instructions for working with feature packages including accounts, teams, billing, auth, and notifications.
|
||||
|
||||
## Feature Package Structure
|
||||
|
||||
- `accounts/` - Personal account management
|
||||
- `admin/` - Super admin functionality
|
||||
- `auth/` - Authentication features
|
||||
- `notifications/` - Notification system
|
||||
- `team-accounts/` - Team account management
|
||||
|
||||
## Account Services
|
||||
|
||||
### Personal Accounts API
|
||||
|
||||
Located at: `packages/features/accounts/src/server/api.ts`
|
||||
|
||||
```typescript
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
|
||||
// Get account data
|
||||
const account = await api.getAccount(accountId);
|
||||
|
||||
// Get account workspace
|
||||
const workspace = await api.getAccountWorkspace();
|
||||
|
||||
// Load user accounts
|
||||
const accounts = await api.loadUserAccounts();
|
||||
|
||||
// Get subscription
|
||||
const subscription = await api.getSubscription(accountId);
|
||||
|
||||
// Get customer ID
|
||||
const customerId = await api.getCustomerId(accountId);
|
||||
```
|
||||
|
||||
### Team Accounts API
|
||||
|
||||
Located at: `packages/features/team-accounts/src/server/api.ts`
|
||||
|
||||
```typescript
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
// Get team account by slug
|
||||
const account = await api.getTeamAccount(slug);
|
||||
|
||||
// Get account workspace
|
||||
const workspace = await api.getAccountWorkspace(slug);
|
||||
|
||||
// Check permissions
|
||||
const hasPermission = await api.hasPermission({
|
||||
accountId,
|
||||
userId,
|
||||
permission: 'billing.manage'
|
||||
});
|
||||
|
||||
// Get members count
|
||||
const count = await api.getMembersCount(accountId);
|
||||
|
||||
// Get invitation
|
||||
const invitation = await api.getInvitation(adminClient, token);
|
||||
```
|
||||
|
||||
## Workspace Contexts
|
||||
|
||||
### Personal Account Context
|
||||
|
||||
Use in `apps/web/app/home/(user)` routes:
|
||||
|
||||
```tsx
|
||||
import { useUserWorkspace } from 'kit/accounts/hooks/use-user-workspace';
|
||||
|
||||
function PersonalComponent() {
|
||||
const { user, account } = useUserWorkspace();
|
||||
|
||||
// user: authenticated user data
|
||||
// account: personal account data
|
||||
|
||||
return <div>Welcome {user.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: `packages/features/accounts/src/components/user-workspace-context-provider.tsx`
|
||||
|
||||
### Team Account Context
|
||||
|
||||
Use in `apps/web/app/home/[account]` routes:
|
||||
|
||||
```tsx
|
||||
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
|
||||
|
||||
function TeamComponent() {
|
||||
const { account, user, accounts } = useTeamAccountWorkspace();
|
||||
|
||||
// account: current team account data
|
||||
// user: authenticated user data
|
||||
// accounts: all accounts user has access to
|
||||
|
||||
return <div>Team: {account.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Context provider: `packages/features/team-accounts/src/components/team-account-workspace-context-provider.tsx`
|
||||
|
||||
## Billing Services
|
||||
|
||||
### Personal Billing
|
||||
|
||||
Located at: `apps/web/app/home/(user)/billing/_lib/server/user-billing.service.ts`
|
||||
|
||||
```typescript
|
||||
// Personal billing operations
|
||||
// - Manage individual user subscriptions
|
||||
// - Handle personal account payments
|
||||
// - Process individual billing changes
|
||||
```
|
||||
|
||||
### Team Billing
|
||||
|
||||
Located at: `apps/web/app/home/[account]/billing/_lib/server/team-billing.service.ts`
|
||||
|
||||
```typescript
|
||||
// Team billing operations
|
||||
// - Manage team subscriptions
|
||||
// - Handle team payments
|
||||
// - Process team billing changes
|
||||
```
|
||||
|
||||
### Per-Seat Billing Service
|
||||
|
||||
Located at: `packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts`
|
||||
|
||||
```typescript
|
||||
import { createAccountPerSeatBillingService } from '@kit/team-accounts/billing';
|
||||
|
||||
const billingService = createAccountPerSeatBillingService(client);
|
||||
|
||||
// Increase seats when adding team members
|
||||
await billingService.increaseSeats(accountId);
|
||||
|
||||
// Decrease seats when removing team members
|
||||
await billingService.decreaseSeats(accountId);
|
||||
|
||||
// Get per-seat subscription item
|
||||
const subscription = await billingService.getPerSeatSubscriptionItem(accountId);
|
||||
```
|
||||
|
||||
## Authentication Features
|
||||
|
||||
### OTP for Sensitive Operations
|
||||
|
||||
Use one-time tokens from `packages/otp/src/api/index.ts`:
|
||||
|
||||
```tsx
|
||||
import { VerifyOtpForm } from '@kit/otp/components';
|
||||
|
||||
<VerifyOtpForm
|
||||
purpose="account-deletion"
|
||||
email={user.email}
|
||||
onSuccess={(otp) => {
|
||||
// Proceed with verified operation
|
||||
handleSensitiveOperation(otp);
|
||||
}}
|
||||
CancelButton={<Button variant="outline">Cancel</Button>}
|
||||
/>
|
||||
```
|
||||
|
||||
## Admin Features
|
||||
|
||||
### Super Admin Protection
|
||||
|
||||
For admin routes, use `AdminGuard`:
|
||||
|
||||
```tsx
|
||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||
|
||||
function AdminPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Admin Dashboard</h1>
|
||||
{/* Admin content */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap the page component
|
||||
export default AdminGuard(AdminPage);
|
||||
```
|
||||
|
||||
### Admin Service
|
||||
|
||||
Located at: `packages/features/admin/src/lib/server/services/admin.service.ts`
|
||||
|
||||
```typescript
|
||||
// Admin service operations
|
||||
// - Manage all accounts
|
||||
// - Handle admin-level operations
|
||||
// - Access system-wide data
|
||||
```
|
||||
|
||||
### Checking Admin Status
|
||||
|
||||
```typescript
|
||||
import { isSuperAdmin } from '@kit/admin';
|
||||
|
||||
function criticalAdminFeature() {
|
||||
const isAdmin = await isSuperAdmin(client);
|
||||
|
||||
if (!isAdmin) {
|
||||
throw new Error('Access denied: Admin privileges required');
|
||||
}
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling & Logging
|
||||
|
||||
### Structured Logging
|
||||
|
||||
Use logger from `packages/shared/src/logger/logger.ts`:
|
||||
|
||||
```typescript
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
async function featureOperation() {
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
name: 'feature-operation',
|
||||
userId: user.id,
|
||||
accountId: account.id
|
||||
};
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Starting feature operation');
|
||||
|
||||
// Perform operation
|
||||
const result = await performOperation();
|
||||
|
||||
logger.info({ ...ctx, result }, 'Feature operation completed');
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'Feature operation failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Permission Patterns
|
||||
|
||||
### Team Permissions
|
||||
|
||||
```typescript
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
// Check if user has specific permission on account
|
||||
const canManageBilling = await api.hasPermission({
|
||||
accountId,
|
||||
userId,
|
||||
permission: 'billing.manage'
|
||||
});
|
||||
|
||||
if (!canManageBilling) {
|
||||
throw new Error('Insufficient permissions');
|
||||
}
|
||||
```
|
||||
|
||||
### Account Ownership
|
||||
|
||||
```typescript
|
||||
// Check if user is account owner (works for both personal and team accounts)
|
||||
const isOwner = await client.rpc('is_account_owner', {
|
||||
account_id: accountId
|
||||
});
|
||||
|
||||
if (!isOwner) {
|
||||
throw new Error('Only account owners can perform this action');
|
||||
}
|
||||
```
|
||||
@@ -20,7 +20,7 @@
|
||||
"nanoid": "^5.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@kit/billing-gateway": "workspace:*",
|
||||
"@kit/email-templates": "workspace:*",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
@@ -34,12 +34,12 @@
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"@types/react": "19.1.12",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"@types/react": "19.1.13",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "15.5.2",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "15.5.3",
|
||||
"next-themes": "0.4.6",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
@@ -20,12 +20,12 @@
|
||||
"@kit/ui": "workspace:*",
|
||||
"@makerkit/data-loader-supabase-core": "^0.0.10",
|
||||
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/react": "19.1.12",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "15.5.2",
|
||||
"@types/react": "19.1.13",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"react-hook-form": "^7.62.0",
|
||||
|
||||
@@ -46,18 +46,18 @@ export function AdminAccountPage(props: {
|
||||
async function PersonalAccountPage(props: { account: Account }) {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
const { data, error } = await adminClient.auth.admin.getUserById(
|
||||
props.account.id,
|
||||
);
|
||||
const [memberships, userResult] = await Promise.all([
|
||||
getMemberships(props.account.id),
|
||||
adminClient.auth.admin.getUserById(props.account.id),
|
||||
]);
|
||||
|
||||
if (!data || error) {
|
||||
throw new Error(`User not found`);
|
||||
if (userResult.error) {
|
||||
throw userResult.error;
|
||||
}
|
||||
|
||||
const memberships = await getMemberships(props.account.id);
|
||||
|
||||
const isBanned =
|
||||
'banned_until' in data.user && data.user.banned_until !== 'none';
|
||||
'banned_until' in userResult.data.user &&
|
||||
userResult.data.user.banned_until !== 'none';
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -168,6 +168,7 @@ function getColumns(): ColumnDef<Account>[] {
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Link
|
||||
prefetch={false}
|
||||
className={'hover:underline'}
|
||||
href={`/admin/accounts/${row.original.id}`}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
@@ -77,11 +79,9 @@ function BanUserForm(props: { userId: string }) {
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await banUserAction(data);
|
||||
|
||||
setError(!result.success);
|
||||
} catch {
|
||||
setError(true);
|
||||
await banUserAction(data);
|
||||
} catch (error) {
|
||||
setError(!isRedirectError(error));
|
||||
}
|
||||
});
|
||||
})}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
@@ -76,11 +78,9 @@ function ReactivateUserForm(props: { userId: string }) {
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await reactivateUserAction(data);
|
||||
|
||||
setError(!result.success);
|
||||
} catch {
|
||||
setError(true);
|
||||
await reactivateUserAction(data);
|
||||
} catch (error) {
|
||||
setError(!isRedirectError(error));
|
||||
}
|
||||
});
|
||||
})}
|
||||
|
||||
@@ -47,9 +47,7 @@ export const banUserAction = adminAction(
|
||||
|
||||
revalidateAdmin();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
return redirect(`/admin/accounts/${userId}`);
|
||||
},
|
||||
{
|
||||
schema: BanUserSchema,
|
||||
@@ -83,9 +81,7 @@ export const reactivateUserAction = adminAction(
|
||||
|
||||
logger.info({ userId }, `Super Admin has successfully reactivated user`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
return redirect(`/admin/accounts/${userId}`);
|
||||
},
|
||||
{
|
||||
schema: ReactivateUserSchema,
|
||||
|
||||
@@ -20,20 +20,20 @@
|
||||
"./oauth-provider-logo-image": "./src/components/oauth-provider-logo-image.tsx"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@marsidev/react-turnstile": "^1.3.0",
|
||||
"@marsidev/react-turnstile": "^1.3.1",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"@types/react": "19.1.12",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "15.5.2",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"@types/react": "19.1.13",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "15.5.3",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-i18next": "^15.7.3",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"@types/react": "19.1.12",
|
||||
"lucide-react": "^0.542.0",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"@types/react": "19.1.13",
|
||||
"lucide-react": "^0.544.0",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"react-i18next": "^15.7.3"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"nanoid": "^5.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@kit/accounts": "workspace:*",
|
||||
"@kit/billing-gateway": "workspace:*",
|
||||
"@kit/email-templates": "workspace:*",
|
||||
@@ -32,15 +32,15 @@
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react": "19.1.13",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "15.5.2",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"react-hook-form": "^7.62.0",
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"next": "15.5.2",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"react-i18next": "^15.7.3"
|
||||
|
||||
66
packages/mailers/AGENTS.md
Normal file
66
packages/mailers/AGENTS.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Email Service Instructions
|
||||
|
||||
This file contains guidance for working with the email service supporting Resend and Nodemailer.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { getMailer } from '@kit/mailers';
|
||||
import { renderAccountDeleteEmail } from '@kit/email-templates';
|
||||
|
||||
async function sendSimpleEmail() {
|
||||
// Get mailer instance
|
||||
const mailer = await getMailer();
|
||||
|
||||
// Send simple email
|
||||
await mailer.sendEmail({
|
||||
to: 'user@example.com',
|
||||
from: 'noreply@yourdomain.com',
|
||||
subject: 'Welcome!',
|
||||
html: '<h1>Welcome!</h1><p>Thank you for joining us.</p>',
|
||||
});
|
||||
}
|
||||
|
||||
async function sendComplexEmail() {
|
||||
// Send with email template
|
||||
const { html, subject } = await renderAccountDeleteEmail({
|
||||
userDisplayName: user.name,
|
||||
productName: 'My SaaS App',
|
||||
});
|
||||
|
||||
await mailer.sendEmail({
|
||||
to: user.email,
|
||||
from: 'noreply@yourdomain.com',
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Email Templates
|
||||
|
||||
Email templates are located in `@kit/email-templates` and return `{ html, subject }`:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
renderAccountDeleteEmail,
|
||||
renderWelcomeEmail,
|
||||
renderPasswordResetEmail
|
||||
} from '@kit/email-templates';
|
||||
|
||||
// Render template
|
||||
const { html, subject } = await renderWelcomeEmail({
|
||||
userDisplayName: 'John Doe',
|
||||
loginUrl: 'https://app.com/login'
|
||||
});
|
||||
|
||||
// Send rendered email
|
||||
const mailer = await getMailer();
|
||||
|
||||
await mailer.sendEmail({
|
||||
to: user.email,
|
||||
from: 'welcome@yourdomain.com',
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
```
|
||||
66
packages/mailers/CLAUDE.md
Normal file
66
packages/mailers/CLAUDE.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Email Service Instructions
|
||||
|
||||
This file contains guidance for working with the email service supporting Resend and Nodemailer.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { getMailer } from '@kit/mailers';
|
||||
import { renderAccountDeleteEmail } from '@kit/email-templates';
|
||||
|
||||
async function sendSimpleEmail() {
|
||||
// Get mailer instance
|
||||
const mailer = await getMailer();
|
||||
|
||||
// Send simple email
|
||||
await mailer.sendEmail({
|
||||
to: 'user@example.com',
|
||||
from: 'noreply@yourdomain.com',
|
||||
subject: 'Welcome!',
|
||||
html: '<h1>Welcome!</h1><p>Thank you for joining us.</p>',
|
||||
});
|
||||
}
|
||||
|
||||
async function sendComplexEmail() {
|
||||
// Send with email template
|
||||
const { html, subject } = await renderAccountDeleteEmail({
|
||||
userDisplayName: user.name,
|
||||
productName: 'My SaaS App',
|
||||
});
|
||||
|
||||
await mailer.sendEmail({
|
||||
to: user.email,
|
||||
from: 'noreply@yourdomain.com',
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Email Templates
|
||||
|
||||
Email templates are located in `@kit/email-templates` and return `{ html, subject }`:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
renderAccountDeleteEmail,
|
||||
renderWelcomeEmail,
|
||||
renderPasswordResetEmail
|
||||
} from '@kit/email-templates';
|
||||
|
||||
// Render template
|
||||
const { html, subject } = await renderWelcomeEmail({
|
||||
userDisplayName: 'John Doe',
|
||||
loginUrl: 'https://app.com/login'
|
||||
});
|
||||
|
||||
// Send rendered email
|
||||
const mailer = await getMailer();
|
||||
|
||||
await mailer.sendEmail({
|
||||
to: user.email,
|
||||
from: 'welcome@yourdomain.com',
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
```
|
||||
@@ -20,7 +20,7 @@
|
||||
"@kit/resend": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/node": "^24.5.0",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"typesVersions": {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@kit/mailers-shared": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/node": "^24.5.0",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"typesVersions": {
|
||||
|
||||
1
packages/mcp-server/.gitignore
vendored
Normal file
1
packages/mcp-server/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
build
|
||||
58
packages/mcp-server/README.md
Normal file
58
packages/mcp-server/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Makerkit MCP Server
|
||||
|
||||
The Makerkit MCP Server provides tools to AI Agents for working with the codebase.
|
||||
|
||||
## Build MCP Server
|
||||
|
||||
Run the command:
|
||||
|
||||
```bash
|
||||
pnpm --filter "@kit/mcp-server" build
|
||||
```
|
||||
|
||||
The command will build the MCP Server at `packages/mcp-server/build/index.js`.
|
||||
|
||||
## Adding MCP Servers to AI Coding tools
|
||||
|
||||
Before getting started, retrieve the absolute path to the `index.js` file created above. You can normally do this in your IDE by right-clicking the `index.js` file and selecting `Copy Path`.
|
||||
|
||||
I will reference this as `<full-path>` in the steps below: please replace it with the full path to your `index.js`.
|
||||
|
||||
### Claude Code
|
||||
|
||||
Run the command below:
|
||||
|
||||
```bash
|
||||
claude mcp add makerkit node <full-path>
|
||||
```
|
||||
|
||||
Restart Claude Code. If no errors appear, the MCP should be correctly configured.
|
||||
|
||||
### Codex
|
||||
|
||||
Open the Codex YAML config and add the following:
|
||||
|
||||
```
|
||||
[mcp_servers.makerkit]
|
||||
command = "node"
|
||||
args = ["<full-path>"]
|
||||
```
|
||||
|
||||
### Cursor
|
||||
|
||||
Open the `mcp.json` config in Cursor and add the following config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"makerkit": {
|
||||
"command": "node",
|
||||
"args": ["<full-path>"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Additional MCP Servers
|
||||
|
||||
I strongly suggest using [the Postgres MCP Server](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres) that allows AI Agents to understand the structure of your Database.
|
||||
3
packages/mcp-server/eslint.config.mjs
Normal file
3
packages/mcp-server/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
31
packages/mcp-server/package.json
Normal file
31
packages/mcp-server/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@kit/mcp-server",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"main": "./build/index.js",
|
||||
"bin": {
|
||||
"makerkit-mcp-server": "./build/index.js"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
|
||||
"build": "tsc && chmod 755 build/index.js",
|
||||
"mcp": "node build/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "1.18.0",
|
||||
"@types/node": "^24.5.0",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"prettier": "@kit/prettier-config"
|
||||
}
|
||||
31
packages/mcp-server/src/index.ts
Normal file
31
packages/mcp-server/src/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
|
||||
import { registerComponentsTools } from './tools/components';
|
||||
import { registerDatabaseTools } from './tools/database';
|
||||
import { registerGetMigrationsTools } from './tools/migrations';
|
||||
import { registerScriptsTools } from './tools/scripts';
|
||||
|
||||
// Create server instance
|
||||
const server = new McpServer({
|
||||
name: 'makerkit',
|
||||
version: '1.0.0',
|
||||
capabilities: {},
|
||||
});
|
||||
|
||||
registerGetMigrationsTools(server);
|
||||
registerDatabaseTools(server);
|
||||
registerComponentsTools(server);
|
||||
registerScriptsTools(server);
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
console.error('Makerkit MCP Server running on stdio');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error in main():', error);
|
||||
process.exit(1);
|
||||
});
|
||||
0
packages/mcp-server/src/server.ts
Normal file
0
packages/mcp-server/src/server.ts
Normal file
493
packages/mcp-server/src/tools/components.ts
Normal file
493
packages/mcp-server/src/tools/components.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface ComponentInfo {
|
||||
name: string;
|
||||
exportPath: string;
|
||||
filePath: string;
|
||||
category: 'shadcn' | 'makerkit' | 'utils';
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class ComponentsTool {
|
||||
static async getComponents(): Promise<ComponentInfo[]> {
|
||||
const packageJsonPath = join(
|
||||
process.cwd(),
|
||||
'packages',
|
||||
'ui',
|
||||
'package.json',
|
||||
);
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
|
||||
|
||||
const components: ComponentInfo[] = [];
|
||||
|
||||
for (const [exportName, filePath] of Object.entries(packageJson.exports)) {
|
||||
if (typeof filePath === 'string' && filePath.endsWith('.tsx')) {
|
||||
const category = this.determineCategory(filePath);
|
||||
const description = await this.generateDescription(
|
||||
exportName,
|
||||
filePath,
|
||||
category,
|
||||
);
|
||||
|
||||
components.push({
|
||||
name: exportName.replace('./', ''),
|
||||
exportPath: exportName,
|
||||
filePath: filePath,
|
||||
category,
|
||||
description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return components.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
static async searchComponents(query: string): Promise<ComponentInfo[]> {
|
||||
const allComponents = await this.getComponents();
|
||||
const searchTerm = query.toLowerCase();
|
||||
|
||||
return allComponents.filter((component) => {
|
||||
return (
|
||||
component.name.toLowerCase().includes(searchTerm) ||
|
||||
component.description.toLowerCase().includes(searchTerm) ||
|
||||
component.category.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static async getComponentProps(componentName: string): Promise<{
|
||||
componentName: string;
|
||||
props: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
optional: boolean;
|
||||
description?: string;
|
||||
}>;
|
||||
interfaces: string[];
|
||||
variants?: Record<string, string[]>;
|
||||
}> {
|
||||
const content = await this.getComponentContent(componentName);
|
||||
|
||||
return {
|
||||
componentName,
|
||||
props: this.extractProps(content),
|
||||
interfaces: this.extractInterfaces(content),
|
||||
variants: this.extractVariants(content),
|
||||
};
|
||||
}
|
||||
|
||||
private static extractProps(content: string): Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
optional: boolean;
|
||||
description?: string;
|
||||
}> {
|
||||
const props: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
optional: boolean;
|
||||
description?: string;
|
||||
}> = [];
|
||||
|
||||
// Look for interface definitions that end with "Props"
|
||||
const interfaceRegex =
|
||||
/interface\s+(\w*Props)\s*(?:extends[^{]*?)?\s*{([^}]*)}/gs;
|
||||
let match;
|
||||
|
||||
while ((match = interfaceRegex.exec(content)) !== null) {
|
||||
const interfaceBody = match[2];
|
||||
const propLines = interfaceBody
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line);
|
||||
|
||||
for (const line of propLines) {
|
||||
// Skip comments and empty lines
|
||||
if (
|
||||
line.startsWith('//') ||
|
||||
line.startsWith('*') ||
|
||||
!line.includes(':')
|
||||
)
|
||||
continue;
|
||||
|
||||
// Extract prop name and type
|
||||
const propMatch = line.match(/(\w+)(\?)?\s*:\s*([^;,]+)/);
|
||||
if (propMatch) {
|
||||
const [, name, optional, type] = propMatch;
|
||||
props.push({
|
||||
name,
|
||||
type: type.trim(),
|
||||
optional: Boolean(optional),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
private static extractInterfaces(content: string): string[] {
|
||||
const interfaces: string[] = [];
|
||||
const interfaceRegex = /(?:export\s+)?interface\s+(\w+)/g;
|
||||
let match;
|
||||
|
||||
while ((match = interfaceRegex.exec(content)) !== null) {
|
||||
interfaces.push(match[1]);
|
||||
}
|
||||
|
||||
return interfaces;
|
||||
}
|
||||
|
||||
private static extractVariants(
|
||||
content: string,
|
||||
): Record<string, string[]> | undefined {
|
||||
// Look for CVA (class-variance-authority) variants
|
||||
const cvaRegex = /cva\s*\([^,]*,\s*{[^}]*variants:\s*{([^}]*)}/s;
|
||||
const match = cvaRegex.exec(content);
|
||||
|
||||
if (!match) return undefined;
|
||||
|
||||
const variantsSection = match[1];
|
||||
const variants: Record<string, string[]> = {};
|
||||
|
||||
// Extract each variant category
|
||||
const variantRegex = /(\w+):\s*{([^}]*)}/g;
|
||||
let variantMatch;
|
||||
|
||||
while ((variantMatch = variantRegex.exec(variantsSection)) !== null) {
|
||||
const [, variantName, variantOptions] = variantMatch;
|
||||
const options: string[] = [];
|
||||
|
||||
// Extract option names
|
||||
const optionRegex = /(\w+):/g;
|
||||
let optionMatch;
|
||||
|
||||
while ((optionMatch = optionRegex.exec(variantOptions)) !== null) {
|
||||
options.push(optionMatch[1]);
|
||||
}
|
||||
|
||||
if (options.length > 0) {
|
||||
variants[variantName] = options;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(variants).length > 0 ? variants : undefined;
|
||||
}
|
||||
|
||||
static async getComponentContent(componentName: string): Promise<string> {
|
||||
const packageJsonPath = join(
|
||||
process.cwd(),
|
||||
'packages',
|
||||
'ui',
|
||||
'package.json',
|
||||
);
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
|
||||
|
||||
const exportPath = `./${componentName}`;
|
||||
const filePath = packageJson.exports[exportPath];
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error(`Component "${componentName}" not found in exports`);
|
||||
}
|
||||
|
||||
const fullPath = join(process.cwd(), 'packages', 'ui', filePath);
|
||||
return readFile(fullPath, 'utf8');
|
||||
}
|
||||
|
||||
private static determineCategory(
|
||||
filePath: string,
|
||||
): 'shadcn' | 'makerkit' | 'utils' {
|
||||
if (filePath.includes('/shadcn/')) return 'shadcn';
|
||||
if (filePath.includes('/makerkit/')) return 'makerkit';
|
||||
return 'utils';
|
||||
}
|
||||
|
||||
private static async generateDescription(
|
||||
exportName: string,
|
||||
_filePath: string,
|
||||
category: 'shadcn' | 'makerkit' | 'utils',
|
||||
): Promise<string> {
|
||||
const componentName = exportName.replace('./', '');
|
||||
|
||||
if (category === 'shadcn') {
|
||||
return this.getShadcnDescription(componentName);
|
||||
} else if (category === 'makerkit') {
|
||||
return this.getMakerkitDescription(componentName);
|
||||
} else {
|
||||
return this.getUtilsDescription(componentName);
|
||||
}
|
||||
}
|
||||
|
||||
private static getShadcnDescription(componentName: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
accordion:
|
||||
'A vertically stacked set of interactive headings that each reveal a section of content',
|
||||
'alert-dialog':
|
||||
'A modal dialog that interrupts the user with important content and expects a response',
|
||||
alert:
|
||||
'Displays a callout for user attention with different severity levels',
|
||||
avatar: 'An image element with a fallback for representing the user',
|
||||
badge: 'A small status descriptor for UI elements',
|
||||
breadcrumb:
|
||||
'Displays the path to the current resource using a hierarchy of links',
|
||||
button: 'Displays a button or a component that looks like a button',
|
||||
calendar:
|
||||
'A date field component that allows users to enter and edit date',
|
||||
card: 'Displays a card with header, content, and footer',
|
||||
chart: 'A collection of chart components built on top of Recharts',
|
||||
checkbox:
|
||||
'A control that allows the user to toggle between checked and not checked',
|
||||
collapsible: 'An interactive component which can be expanded/collapsed',
|
||||
command: 'A fast, composable, unstyled command menu for React',
|
||||
'data-table': 'A powerful table component built on top of TanStack Table',
|
||||
dialog:
|
||||
'A window overlaid on either the primary window or another dialog window',
|
||||
'dropdown-menu': 'Displays a menu to the user triggered by a button',
|
||||
form: 'Building forms with validation and error handling',
|
||||
heading: 'Typography component for displaying headings',
|
||||
input:
|
||||
'Displays a form input field or a component that looks like an input field',
|
||||
'input-otp':
|
||||
'Accessible one-time password component with copy paste functionality',
|
||||
label: 'Renders an accessible label associated with controls',
|
||||
'navigation-menu': 'A collection of links for navigating websites',
|
||||
popover: 'Displays rich content in a portal, triggered by a button',
|
||||
progress:
|
||||
'Displays an indicator showing the completion progress of a task',
|
||||
'radio-group':
|
||||
'A set of checkable buttons where no more than one can be checked at a time',
|
||||
'scroll-area':
|
||||
'Augments native scroll functionality for custom, cross-browser styling',
|
||||
select: 'Displays a list of options for the user to pick from',
|
||||
separator: 'Visually or semantically separates content',
|
||||
sheet:
|
||||
'Extends the Dialog component to display content that complements the main content of the screen',
|
||||
sidebar: 'A collapsible sidebar component with navigation',
|
||||
skeleton: 'Use to show a placeholder while content is loading',
|
||||
slider:
|
||||
'An input where the user selects a value from within a given range',
|
||||
sonner: 'An opinionated toast component for React',
|
||||
switch:
|
||||
'A control that allows the user to toggle between checked and not checked',
|
||||
table: 'A responsive table component',
|
||||
tabs: 'A set of layered sections of content - known as tab panels - that are displayed one at a time',
|
||||
textarea:
|
||||
'Displays a form textarea or a component that looks like a textarea',
|
||||
tooltip:
|
||||
'A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it',
|
||||
};
|
||||
|
||||
return (
|
||||
descriptions[componentName] || `Shadcn UI component: ${componentName}`
|
||||
);
|
||||
}
|
||||
|
||||
private static getMakerkitDescription(componentName: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
if: 'Conditional rendering component that shows children only when condition is true',
|
||||
trans:
|
||||
'Internationalization component for translating text with interpolation support',
|
||||
sidebar:
|
||||
'Application sidebar component with navigation and collapsible functionality',
|
||||
'bordered-navigation-menu':
|
||||
'Navigation menu component with bordered styling',
|
||||
spinner: 'Loading spinner component with customizable size and styling',
|
||||
page: 'Page layout component that provides consistent structure and styling',
|
||||
'image-uploader':
|
||||
'Component for uploading and displaying images with drag-and-drop support',
|
||||
'global-loader':
|
||||
'Global loading indicator component for application-wide loading states',
|
||||
'loading-overlay':
|
||||
'Overlay component that shows loading state over content',
|
||||
'profile-avatar':
|
||||
'User profile avatar component with fallback and customization options',
|
||||
'enhanced-data-table':
|
||||
'Enhanced data table component with sorting, filtering, and pagination (best table component)',
|
||||
'language-selector':
|
||||
'Component for selecting application language/locale',
|
||||
stepper: 'Step-by-step navigation component for multi-step processes',
|
||||
'card-button': 'Clickable card component that acts as a button',
|
||||
'multi-step-form':
|
||||
'Multi-step form component with validation and navigation',
|
||||
'app-breadcrumbs': 'Application breadcrumb navigation component',
|
||||
'empty-state':
|
||||
'Component for displaying empty states with customizable content',
|
||||
marketing: 'Collection of marketing-focused components and layouts',
|
||||
'file-uploader':
|
||||
'File upload component with drag-and-drop and preview functionality',
|
||||
};
|
||||
|
||||
return (
|
||||
descriptions[componentName] ||
|
||||
`Makerkit custom component: ${componentName}`
|
||||
);
|
||||
}
|
||||
|
||||
private static getUtilsDescription(componentName: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
utils:
|
||||
'Utility functions for styling, class management, and common operations',
|
||||
'navigation-schema': 'Schema and types for navigation configuration',
|
||||
};
|
||||
|
||||
return descriptions[componentName] || `Utility module: ${componentName}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerComponentsTools(server: McpServer) {
|
||||
createGetComponentsTool(server);
|
||||
createGetComponentContentTool(server);
|
||||
createComponentsSearchTool(server);
|
||||
createGetComponentPropsTool(server);
|
||||
}
|
||||
|
||||
function createGetComponentsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_components',
|
||||
'Get all available UI components from the @kit/ui package with descriptions',
|
||||
async () => {
|
||||
const components = await ComponentsTool.getComponents();
|
||||
|
||||
const componentsList = components
|
||||
.map(
|
||||
(component) =>
|
||||
`${component.name} (${component.category}): ${component.description}`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: componentsList,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetComponentContentTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_component_content',
|
||||
'Get the source code content of a specific UI component',
|
||||
{
|
||||
state: z.object({
|
||||
componentName: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const content = await ComponentsTool.getComponentContent(
|
||||
state.componentName,
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: content,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createComponentsSearchTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'components_search',
|
||||
'Search UI components by keyword in name, description, or category',
|
||||
{
|
||||
state: z.object({
|
||||
query: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const components = await ComponentsTool.searchComponents(state.query);
|
||||
|
||||
if (components.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `No components found matching "${state.query}"`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const componentsList = components
|
||||
.map(
|
||||
(component) =>
|
||||
`${component.name} (${component.category}): ${component.description}`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Found ${components.length} components matching "${state.query}":\n\n${componentsList}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetComponentPropsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_component_props',
|
||||
'Extract component props, interfaces, and variants from a UI component',
|
||||
{
|
||||
state: z.object({
|
||||
componentName: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const propsInfo = await ComponentsTool.getComponentProps(
|
||||
state.componentName,
|
||||
);
|
||||
|
||||
let result = `Component: ${propsInfo.componentName}\n\n`;
|
||||
|
||||
if (propsInfo.interfaces.length > 0) {
|
||||
result += `Interfaces: ${propsInfo.interfaces.join(', ')}\n\n`;
|
||||
}
|
||||
|
||||
if (propsInfo.props.length > 0) {
|
||||
result += `Props:\n`;
|
||||
propsInfo.props.forEach((prop) => {
|
||||
const optional = prop.optional ? '?' : '';
|
||||
result += ` - ${prop.name}${optional}: ${prop.type}\n`;
|
||||
});
|
||||
result += '\n';
|
||||
}
|
||||
|
||||
if (propsInfo.variants) {
|
||||
result += `Variants (CVA):\n`;
|
||||
Object.entries(propsInfo.variants).forEach(([variantName, options]) => {
|
||||
result += ` - ${variantName}: ${options.join(' | ')}\n`;
|
||||
});
|
||||
result += '\n';
|
||||
}
|
||||
|
||||
if (propsInfo.props.length === 0 && !propsInfo.variants) {
|
||||
result +=
|
||||
'No props or variants found. This might be a simple component or utility.';
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: result,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
706
packages/mcp-server/src/tools/database.ts
Normal file
706
packages/mcp-server/src/tools/database.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface DatabaseFunction {
|
||||
name: string;
|
||||
parameters: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
defaultValue?: string;
|
||||
}>;
|
||||
returnType: string;
|
||||
description: string;
|
||||
purpose: string;
|
||||
securityLevel: 'definer' | 'invoker';
|
||||
schema: string;
|
||||
sourceFile: string;
|
||||
}
|
||||
|
||||
interface SchemaFile {
|
||||
name: string;
|
||||
path: string;
|
||||
description: string;
|
||||
section: string;
|
||||
lastModified: Date;
|
||||
tables: string[];
|
||||
functions: string[];
|
||||
dependencies: string[];
|
||||
topic: string;
|
||||
}
|
||||
|
||||
export class DatabaseTool {
|
||||
static async getSchemaFiles(): Promise<SchemaFile[]> {
|
||||
const schemasPath = join(
|
||||
process.cwd(),
|
||||
'apps',
|
||||
'web',
|
||||
'supabase',
|
||||
'schemas',
|
||||
);
|
||||
const files = await readdir(schemasPath);
|
||||
|
||||
const schemaFiles: SchemaFile[] = [];
|
||||
|
||||
for (const file of files.filter((f) => f.endsWith('.sql'))) {
|
||||
const filePath = join(schemasPath, file);
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const stats = await stat(filePath);
|
||||
|
||||
// Extract section and description from the file header
|
||||
const sectionMatch = content.match(/\* Section: ([^\n*]+)/);
|
||||
const descriptionMatch = content.match(/\* ([^*\n]+)\n \* We create/);
|
||||
|
||||
// Extract tables and functions from content
|
||||
const tables = this.extractTables(content);
|
||||
const functions = this.extractFunctionNames(content);
|
||||
const dependencies = this.extractDependencies(content);
|
||||
const topic = this.determineTopic(file, content);
|
||||
|
||||
schemaFiles.push({
|
||||
name: file,
|
||||
path: filePath,
|
||||
section: sectionMatch?.[1]?.trim() || 'Unknown',
|
||||
description:
|
||||
descriptionMatch?.[1]?.trim() || 'No description available',
|
||||
lastModified: stats.mtime,
|
||||
tables,
|
||||
functions,
|
||||
dependencies,
|
||||
topic,
|
||||
});
|
||||
}
|
||||
|
||||
return schemaFiles.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
static async getFunctions(): Promise<DatabaseFunction[]> {
|
||||
const schemaFiles = await this.getSchemaFiles();
|
||||
const functions: DatabaseFunction[] = [];
|
||||
|
||||
for (const schemaFile of schemaFiles) {
|
||||
const content = await readFile(schemaFile.path, 'utf8');
|
||||
const fileFunctions = this.extractFunctionsFromContent(
|
||||
content,
|
||||
schemaFile.name,
|
||||
);
|
||||
functions.push(...fileFunctions);
|
||||
}
|
||||
|
||||
return functions.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
static async getFunctionDetails(
|
||||
functionName: string,
|
||||
): Promise<DatabaseFunction> {
|
||||
const functions = await this.getFunctions();
|
||||
const func = functions.find((f) => f.name === functionName);
|
||||
|
||||
if (!func) {
|
||||
throw new Error(`Function "${functionName}" not found`);
|
||||
}
|
||||
|
||||
return func;
|
||||
}
|
||||
|
||||
static async searchFunctions(query: string): Promise<DatabaseFunction[]> {
|
||||
const allFunctions = await this.getFunctions();
|
||||
const searchTerm = query.toLowerCase();
|
||||
|
||||
return allFunctions.filter((func) => {
|
||||
return (
|
||||
func.name.toLowerCase().includes(searchTerm) ||
|
||||
func.description.toLowerCase().includes(searchTerm) ||
|
||||
func.purpose.toLowerCase().includes(searchTerm) ||
|
||||
func.returnType.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static async getSchemaContent(fileName: string): Promise<string> {
|
||||
const schemasPath = join(
|
||||
process.cwd(),
|
||||
'apps',
|
||||
'web',
|
||||
'supabase',
|
||||
'schemas',
|
||||
);
|
||||
const filePath = join(schemasPath, fileName);
|
||||
|
||||
try {
|
||||
return await readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
throw new Error(`Schema file "${fileName}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
static async getSchemasByTopic(topic: string): Promise<SchemaFile[]> {
|
||||
const allSchemas = await this.getSchemaFiles();
|
||||
const searchTerm = topic.toLowerCase();
|
||||
|
||||
return allSchemas.filter((schema) => {
|
||||
return (
|
||||
schema.topic.toLowerCase().includes(searchTerm) ||
|
||||
schema.section.toLowerCase().includes(searchTerm) ||
|
||||
schema.description.toLowerCase().includes(searchTerm) ||
|
||||
schema.name.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static async getSchemaBySection(section: string): Promise<SchemaFile | null> {
|
||||
const allSchemas = await this.getSchemaFiles();
|
||||
return (
|
||||
allSchemas.find(
|
||||
(schema) => schema.section.toLowerCase() === section.toLowerCase(),
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
private static extractFunctionsFromContent(
|
||||
content: string,
|
||||
sourceFile: string,
|
||||
): DatabaseFunction[] {
|
||||
const functions: DatabaseFunction[] = [];
|
||||
|
||||
// Updated regex to capture function definitions with optional "or replace"
|
||||
const functionRegex =
|
||||
/create\s+(?:or\s+replace\s+)?function\s+([a-zA-Z_][a-zA-Z0-9_.]*)\s*\(([^)]*)\)\s*returns?\s+([^;\n]+)(?:\s+language\s+\w+)?(?:\s+security\s+(definer|invoker))?[^$]*?\$\$([^$]*)\$\$/gi;
|
||||
|
||||
let match;
|
||||
while ((match = functionRegex.exec(content)) !== null) {
|
||||
const [, fullName, params, returnType, securityLevel, body] = match;
|
||||
|
||||
if (!fullName || !returnType) continue;
|
||||
|
||||
// Extract schema and function name
|
||||
const nameParts = fullName.split('.');
|
||||
const functionName = nameParts[nameParts.length - 1];
|
||||
const schema = nameParts.length > 1 ? nameParts[0] : 'public';
|
||||
|
||||
// Parse parameters
|
||||
const parameters = this.parseParameters(params || '');
|
||||
|
||||
// Extract description and purpose from comments before function
|
||||
const functionIndex = match.index || 0;
|
||||
const beforeFunction = content.substring(
|
||||
Math.max(0, functionIndex - 500),
|
||||
functionIndex,
|
||||
);
|
||||
const description = this.extractDescription(beforeFunction, body || '');
|
||||
const purpose = this.extractPurpose(description, functionName);
|
||||
|
||||
functions.push({
|
||||
name: functionName,
|
||||
parameters,
|
||||
returnType: returnType.trim(),
|
||||
description,
|
||||
purpose,
|
||||
securityLevel: (securityLevel as 'definer' | 'invoker') || 'invoker',
|
||||
schema,
|
||||
sourceFile,
|
||||
});
|
||||
}
|
||||
|
||||
return functions;
|
||||
}
|
||||
|
||||
private static parseParameters(paramString: string): Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
defaultValue?: string;
|
||||
}> {
|
||||
if (!paramString.trim()) return [];
|
||||
|
||||
const parameters: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
defaultValue?: string;
|
||||
}> = [];
|
||||
|
||||
// Split by comma, but be careful of nested types
|
||||
const params = paramString.split(',');
|
||||
|
||||
for (const param of params) {
|
||||
const cleaned = param.trim();
|
||||
if (!cleaned) continue;
|
||||
|
||||
// Match parameter pattern: name type [default value]
|
||||
const paramMatch = cleaned.match(
|
||||
/^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s+([^=\s]+)(?:\s+default\s+(.+))?\s*$/i,
|
||||
);
|
||||
|
||||
if (paramMatch) {
|
||||
const [, name, type, defaultValue] = paramMatch;
|
||||
if (name && type) {
|
||||
parameters.push({
|
||||
name: name.trim(),
|
||||
type: type.trim(),
|
||||
defaultValue: defaultValue?.trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
private static extractDescription(
|
||||
beforeFunction: string,
|
||||
body: string,
|
||||
): string {
|
||||
// Look for comments before the function
|
||||
const commentMatch = beforeFunction.match(/--\s*(.+?)(?:\n|$)/);
|
||||
if (commentMatch?.[1]) {
|
||||
return commentMatch[1].trim();
|
||||
}
|
||||
|
||||
// Look for comments inside the function body
|
||||
const bodyCommentMatch = body.match(/--\s*(.+?)(?:\n|$)/);
|
||||
if (bodyCommentMatch?.[1]) {
|
||||
return bodyCommentMatch[1].trim();
|
||||
}
|
||||
|
||||
return 'No description available';
|
||||
}
|
||||
|
||||
private static extractPurpose(
|
||||
description: string,
|
||||
functionName: string,
|
||||
): string {
|
||||
// Map function names to purposes
|
||||
const purposeMap: Record<string, string> = {
|
||||
create_nonce:
|
||||
'Create one-time authentication tokens for secure operations',
|
||||
verify_nonce: 'Verify and consume one-time tokens for authentication',
|
||||
is_mfa_compliant:
|
||||
'Check if user has completed multi-factor authentication',
|
||||
team_account_workspace:
|
||||
'Load comprehensive team account data with permissions',
|
||||
has_role_on_account: 'Check if user has access to a specific account',
|
||||
has_permission: 'Verify user permissions for specific account operations',
|
||||
get_user_billing_account: 'Retrieve billing account information for user',
|
||||
create_team_account: 'Create new team account with proper permissions',
|
||||
invite_user_to_account: 'Send invitation to join team account',
|
||||
accept_invitation: 'Process and accept team invitation',
|
||||
transfer_account_ownership: 'Transfer account ownership between users',
|
||||
delete_account: 'Safely delete account and associated data',
|
||||
};
|
||||
|
||||
if (purposeMap[functionName]) {
|
||||
return purposeMap[functionName];
|
||||
}
|
||||
|
||||
// Analyze function name for purpose hints
|
||||
if (functionName.includes('create'))
|
||||
return 'Create database records with validation';
|
||||
if (functionName.includes('delete') || functionName.includes('remove'))
|
||||
return 'Delete records with proper authorization';
|
||||
if (functionName.includes('update') || functionName.includes('modify'))
|
||||
return 'Update existing records with validation';
|
||||
if (functionName.includes('get') || functionName.includes('fetch'))
|
||||
return 'Retrieve data with access control';
|
||||
if (functionName.includes('verify') || functionName.includes('validate'))
|
||||
return 'Validate data or permissions';
|
||||
if (functionName.includes('check') || functionName.includes('is_'))
|
||||
return 'Check conditions or permissions';
|
||||
if (functionName.includes('invite'))
|
||||
return 'Handle user invitations and access';
|
||||
if (functionName.includes('transfer'))
|
||||
return 'Transfer ownership or data between entities';
|
||||
|
||||
return `Custom database function: ${description}`;
|
||||
}
|
||||
|
||||
private static extractTables(content: string): string[] {
|
||||
const tables: string[] = [];
|
||||
const tableRegex =
|
||||
/create\s+table\s+(?:if\s+not\s+exists\s+)?(?:public\.)?([a-zA-Z_][a-zA-Z0-9_]*)/gi;
|
||||
let match;
|
||||
|
||||
while ((match = tableRegex.exec(content)) !== null) {
|
||||
if (match[1]) {
|
||||
tables.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(tables)]; // Remove duplicates
|
||||
}
|
||||
|
||||
private static extractFunctionNames(content: string): string[] {
|
||||
const functions: string[] = [];
|
||||
const functionRegex =
|
||||
/create\s+(?:or\s+replace\s+)?function\s+(?:public\.)?([a-zA-Z_][a-zA-Z0-9_]*)/gi;
|
||||
let match;
|
||||
|
||||
while ((match = functionRegex.exec(content)) !== null) {
|
||||
if (match[1]) {
|
||||
functions.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(functions)]; // Remove duplicates
|
||||
}
|
||||
|
||||
private static extractDependencies(content: string): string[] {
|
||||
const dependencies: string[] = [];
|
||||
|
||||
// Look for references to other tables
|
||||
const referencesRegex =
|
||||
/references\s+(?:public\.)?([a-zA-Z_][a-zA-Z0-9_]*)/gi;
|
||||
let match;
|
||||
|
||||
while ((match = referencesRegex.exec(content)) !== null) {
|
||||
if (match[1] && match[1] !== 'users') {
|
||||
// Exclude auth.users as it's external
|
||||
dependencies.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(dependencies)]; // Remove duplicates
|
||||
}
|
||||
|
||||
private static determineTopic(fileName: string, content: string): string {
|
||||
// Map file names to topics
|
||||
const fileTopicMap: Record<string, string> = {
|
||||
'00-privileges.sql': 'security',
|
||||
'01-enums.sql': 'types',
|
||||
'02-config.sql': 'configuration',
|
||||
'03-accounts.sql': 'accounts',
|
||||
'04-roles.sql': 'permissions',
|
||||
'05-memberships.sql': 'teams',
|
||||
'06-roles-permissions.sql': 'permissions',
|
||||
'07-invitations.sql': 'teams',
|
||||
'08-billing-customers.sql': 'billing',
|
||||
'09-subscriptions.sql': 'billing',
|
||||
'10-orders.sql': 'billing',
|
||||
'11-notifications.sql': 'notifications',
|
||||
'12-one-time-tokens.sql': 'auth',
|
||||
'13-mfa.sql': 'auth',
|
||||
'14-super-admin.sql': 'admin',
|
||||
'15-account-views.sql': 'accounts',
|
||||
'16-storage.sql': 'storage',
|
||||
'17-roles-seed.sql': 'permissions',
|
||||
};
|
||||
|
||||
if (fileTopicMap[fileName]) {
|
||||
return fileTopicMap[fileName];
|
||||
}
|
||||
|
||||
// Analyze content for topic hints
|
||||
const contentLower = content.toLowerCase();
|
||||
if (contentLower.includes('account') && contentLower.includes('team'))
|
||||
return 'accounts';
|
||||
if (
|
||||
contentLower.includes('subscription') ||
|
||||
contentLower.includes('billing')
|
||||
)
|
||||
return 'billing';
|
||||
if (
|
||||
contentLower.includes('auth') ||
|
||||
contentLower.includes('mfa') ||
|
||||
contentLower.includes('token')
|
||||
)
|
||||
return 'auth';
|
||||
if (contentLower.includes('permission') || contentLower.includes('role'))
|
||||
return 'permissions';
|
||||
if (contentLower.includes('notification') || contentLower.includes('email'))
|
||||
return 'notifications';
|
||||
if (contentLower.includes('storage') || contentLower.includes('bucket'))
|
||||
return 'storage';
|
||||
if (contentLower.includes('admin') || contentLower.includes('super'))
|
||||
return 'admin';
|
||||
|
||||
return 'general';
|
||||
}
|
||||
}
|
||||
|
||||
export function registerDatabaseTools(server: McpServer) {
|
||||
createGetSchemaFilesTool(server);
|
||||
createGetSchemaContentTool(server);
|
||||
createGetSchemasByTopicTool(server);
|
||||
createGetSchemaBySectionTool(server);
|
||||
createGetFunctionsTool(server);
|
||||
createGetFunctionDetailsTool(server);
|
||||
createSearchFunctionsTool(server);
|
||||
}
|
||||
|
||||
function createGetSchemaFilesTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_schema_files',
|
||||
'🔥 DATABASE SCHEMA FILES (SOURCE OF TRUTH - ALWAYS CURRENT) - Use these over migrations!',
|
||||
async () => {
|
||||
const schemaFiles = await DatabaseTool.getSchemaFiles();
|
||||
|
||||
const filesList = schemaFiles
|
||||
.map((file) => {
|
||||
const tablesInfo =
|
||||
file.tables.length > 0
|
||||
? ` | Tables: ${file.tables.join(', ')}`
|
||||
: '';
|
||||
const functionsInfo =
|
||||
file.functions.length > 0
|
||||
? ` | Functions: ${file.functions.join(', ')}`
|
||||
: '';
|
||||
return `${file.name} (${file.topic}): ${file.section} - ${file.description}${tablesInfo}${functionsInfo}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `🔥 DATABASE SCHEMA FILES (ALWAYS UP TO DATE)\n\nThese files represent the current database state. Use these instead of migrations for current schema understanding.\n\n${filesList}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetFunctionsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_database_functions',
|
||||
'Get all database functions with descriptions and usage guidance',
|
||||
async () => {
|
||||
const functions = await DatabaseTool.getFunctions();
|
||||
|
||||
const functionsList = functions
|
||||
.map((func) => {
|
||||
const security =
|
||||
func.securityLevel === 'definer' ? ' [SECURITY DEFINER]' : '';
|
||||
const params = func.parameters
|
||||
.map((p) => {
|
||||
const defaultVal = p.defaultValue ? ` = ${p.defaultValue}` : '';
|
||||
return `${p.name}: ${p.type}${defaultVal}`;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
return `${func.name}(${params}) <20> ${func.returnType}${security}\n Purpose: ${func.purpose}\n Source: ${func.sourceFile}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Database Functions:\n\n${functionsList}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetFunctionDetailsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_function_details',
|
||||
'Get detailed information about a specific database function',
|
||||
{
|
||||
state: z.object({
|
||||
functionName: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const func = await DatabaseTool.getFunctionDetails(state.functionName);
|
||||
|
||||
const params =
|
||||
func.parameters.length > 0
|
||||
? func.parameters
|
||||
.map((p) => {
|
||||
const defaultVal = p.defaultValue
|
||||
? ` (default: ${p.defaultValue})`
|
||||
: '';
|
||||
return ` - ${p.name}: ${p.type}${defaultVal}`;
|
||||
})
|
||||
.join('\n')
|
||||
: ' No parameters';
|
||||
|
||||
const securityNote =
|
||||
func.securityLevel === 'definer'
|
||||
? '\n<> SECURITY DEFINER: This function runs with elevated privileges and bypasses RLS.'
|
||||
: '\n SECURITY INVOKER: This function inherits caller permissions and respects RLS.';
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Function: ${func.schema}.${func.name}
|
||||
|
||||
Purpose: ${func.purpose}
|
||||
Description: ${func.description}
|
||||
Return Type: ${func.returnType}
|
||||
Security Level: ${func.securityLevel}${securityNote}
|
||||
|
||||
Parameters:
|
||||
${params}
|
||||
|
||||
Source File: ${func.sourceFile}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createSearchFunctionsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'search_database_functions',
|
||||
'Search database functions by name, description, or purpose',
|
||||
{
|
||||
state: z.object({
|
||||
query: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const functions = await DatabaseTool.searchFunctions(state.query);
|
||||
|
||||
if (functions.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `No database functions found matching "${state.query}"`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const functionsList = functions
|
||||
.map((func) => {
|
||||
const security = func.securityLevel === 'definer' ? ' [DEFINER]' : '';
|
||||
return `${func.name}${security}: ${func.purpose}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Found ${functions.length} functions matching "${state.query}":\n\n${functionsList}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetSchemaContentTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_schema_content',
|
||||
'📋 Get raw schema file content (CURRENT DATABASE STATE) - Source of truth for database structure',
|
||||
{
|
||||
state: z.object({
|
||||
fileName: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const content = await DatabaseTool.getSchemaContent(state.fileName);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `📋 SCHEMA FILE: ${state.fileName} (CURRENT STATE)\n\n${content}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetSchemasByTopicTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_schemas_by_topic',
|
||||
'🎯 Find schema files by topic (accounts, auth, billing, permissions, etc.) - Fastest way to find relevant schemas',
|
||||
{
|
||||
state: z.object({
|
||||
topic: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const schemas = await DatabaseTool.getSchemasByTopic(state.topic);
|
||||
|
||||
if (schemas.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `No schema files found for topic "${state.topic}". Available topics: accounts, auth, billing, permissions, teams, notifications, storage, admin, security, types, configuration.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const schemasList = schemas
|
||||
.map((schema) => {
|
||||
const tablesInfo =
|
||||
schema.tables.length > 0
|
||||
? `\n Tables: ${schema.tables.join(', ')}`
|
||||
: '';
|
||||
const functionsInfo =
|
||||
schema.functions.length > 0
|
||||
? `\n Functions: ${schema.functions.join(', ')}`
|
||||
: '';
|
||||
return `${schema.name}: ${schema.description}${tablesInfo}${functionsInfo}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `🎯 SCHEMAS FOR TOPIC: "${state.topic}"\n\n${schemasList}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetSchemaBySectionTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_schema_by_section',
|
||||
'📂 Get specific schema by section name (Accounts, Permissions, etc.) - Direct access to schema sections',
|
||||
{
|
||||
state: z.object({
|
||||
section: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const schema = await DatabaseTool.getSchemaBySection(state.section);
|
||||
|
||||
if (!schema) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `No schema found for section "${state.section}". Use get_schema_files to see available sections.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const tablesInfo =
|
||||
schema.tables.length > 0 ? `\nTables: ${schema.tables.join(', ')}` : '';
|
||||
const functionsInfo =
|
||||
schema.functions.length > 0
|
||||
? `\nFunctions: ${schema.functions.join(', ')}`
|
||||
: '';
|
||||
const dependenciesInfo =
|
||||
schema.dependencies.length > 0
|
||||
? `\nDependencies: ${schema.dependencies.join(', ')}`
|
||||
: '';
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `📂 SCHEMA SECTION: ${schema.section}\n\nFile: ${schema.name}\nTopic: ${schema.topic}\nDescription: ${schema.description}${tablesInfo}${functionsInfo}${dependenciesInfo}\n\nLast Modified: ${schema.lastModified.toISOString()}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
122
packages/mcp-server/src/tools/migrations.ts
Normal file
122
packages/mcp-server/src/tools/migrations.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { exec } from 'node:child_process';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { z } from 'zod';
|
||||
|
||||
export class MigrationsTool {
|
||||
static GetMigrations() {
|
||||
return readdir(
|
||||
join(process.cwd(), 'apps', 'web', 'supabase', 'migrations'),
|
||||
);
|
||||
}
|
||||
|
||||
static getMigrationContent(path: string) {
|
||||
return readFile(
|
||||
join(process.cwd(), 'apps', 'web', 'supabase', 'migrations', path),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
static CreateMigration(path: string) {
|
||||
return promisify(exec)(`supabase migration new ${path}`);
|
||||
}
|
||||
|
||||
static Diff() {
|
||||
return promisify(exec)(`supabase migration diff`);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerGetMigrationsTools(server: McpServer) {
|
||||
createGetMigrationsTool(server);
|
||||
createGetMigrationContentTool(server);
|
||||
createCreateMigrationTool(server);
|
||||
createDiffMigrationTool(server);
|
||||
}
|
||||
|
||||
function createDiffMigrationTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'diff_migrations',
|
||||
'Compare differences between the declarative schemas and the applied migrations in Supabase',
|
||||
async () => {
|
||||
const { stdout } = await MigrationsTool.Diff();
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: stdout,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createCreateMigrationTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'create_migration',
|
||||
'Create a new Supabase Postgres migration file',
|
||||
{
|
||||
state: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const { stdout } = await MigrationsTool.CreateMigration(state.name);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: stdout,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetMigrationContentTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_migration_content',
|
||||
'📜 Get migration file content (HISTORICAL) - For current state use get_schema_content instead',
|
||||
{
|
||||
state: z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const content = await MigrationsTool.getMigrationContent(state.path);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `📜 MIGRATION FILE: ${state.path} (HISTORICAL)\n\nNote: This shows historical changes. For current database state, use get_schema_content instead.\n\n${content}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetMigrationsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_migrations',
|
||||
'📜 Get migration files (HISTORICAL CHANGES) - Use schema files for current state instead',
|
||||
async () => {
|
||||
const migrations = await MigrationsTool.GetMigrations();
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `📜 MIGRATION FILES (HISTORICAL CHANGES)\n\nNote: For current database state, use get_schema_files instead. Migrations show historical changes.\n\n${migrations.join('\n')}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
323
packages/mcp-server/src/tools/scripts.ts
Normal file
323
packages/mcp-server/src/tools/scripts.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface ScriptInfo {
|
||||
name: string;
|
||||
command: string;
|
||||
category:
|
||||
| 'development'
|
||||
| 'build'
|
||||
| 'testing'
|
||||
| 'linting'
|
||||
| 'database'
|
||||
| 'maintenance'
|
||||
| 'environment';
|
||||
description: string;
|
||||
usage: string;
|
||||
importance: 'critical' | 'high' | 'medium' | 'low';
|
||||
healthcheck?: boolean;
|
||||
}
|
||||
|
||||
export class ScriptsTool {
|
||||
static async getScripts(): Promise<ScriptInfo[]> {
|
||||
const packageJsonPath = join(process.cwd(), 'package.json');
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
|
||||
|
||||
const scripts: ScriptInfo[] = [];
|
||||
|
||||
for (const [scriptName, command] of Object.entries(packageJson.scripts)) {
|
||||
if (typeof command === 'string') {
|
||||
const scriptInfo = this.getScriptInfo(scriptName, command);
|
||||
scripts.push(scriptInfo);
|
||||
}
|
||||
}
|
||||
|
||||
return scripts.sort((a, b) => {
|
||||
const importanceOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
return importanceOrder[a.importance] - importanceOrder[b.importance];
|
||||
});
|
||||
}
|
||||
|
||||
static async getScriptDetails(scriptName: string): Promise<ScriptInfo> {
|
||||
const packageJsonPath = join(process.cwd(), 'package.json');
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
|
||||
|
||||
const command = packageJson.scripts[scriptName];
|
||||
if (!command) {
|
||||
throw new Error(`Script "${scriptName}" not found`);
|
||||
}
|
||||
|
||||
return this.getScriptInfo(scriptName, command);
|
||||
}
|
||||
|
||||
private static getScriptInfo(
|
||||
scriptName: string,
|
||||
command: string,
|
||||
): ScriptInfo {
|
||||
const scriptDescriptions: Record<
|
||||
string,
|
||||
Omit<ScriptInfo, 'name' | 'command'>
|
||||
> = {
|
||||
dev: {
|
||||
category: 'development',
|
||||
description:
|
||||
'Start development servers for all applications in parallel with hot reloading',
|
||||
usage: 'Run this to start developing. Opens web app on port 3000.',
|
||||
importance: 'medium',
|
||||
},
|
||||
build: {
|
||||
category: 'build',
|
||||
description:
|
||||
'Build all applications and packages for production deployment',
|
||||
usage:
|
||||
'Use before deploying to production. Ensures all code compiles correctly.',
|
||||
importance: 'medium',
|
||||
},
|
||||
typecheck: {
|
||||
category: 'linting',
|
||||
description:
|
||||
'Run TypeScript compiler to check for type errors across all packages',
|
||||
usage:
|
||||
'CRITICAL: Run after writing code to ensure type safety. Must pass before commits.',
|
||||
importance: 'critical',
|
||||
healthcheck: true,
|
||||
},
|
||||
lint: {
|
||||
category: 'linting',
|
||||
description:
|
||||
'Run ESLint to check code quality and enforce coding standards',
|
||||
usage:
|
||||
'CRITICAL: Run after writing code to ensure code quality. Must pass before commits.',
|
||||
importance: 'medium',
|
||||
healthcheck: true,
|
||||
},
|
||||
'lint:fix': {
|
||||
category: 'linting',
|
||||
description:
|
||||
'Run ESLint with auto-fix to automatically resolve fixable issues',
|
||||
usage:
|
||||
'Use to automatically fix linting issues. Run before manual fixes.',
|
||||
importance: 'high',
|
||||
healthcheck: true,
|
||||
},
|
||||
format: {
|
||||
category: 'linting',
|
||||
description: 'Check code formatting with Prettier across all files',
|
||||
usage: 'Verify code follows consistent formatting standards.',
|
||||
importance: 'high',
|
||||
},
|
||||
'format:fix': {
|
||||
category: 'linting',
|
||||
description:
|
||||
'Auto-format all code with Prettier to ensure consistent styling',
|
||||
usage: 'Use to automatically format code. Run before commits.',
|
||||
importance: 'high',
|
||||
healthcheck: true,
|
||||
},
|
||||
test: {
|
||||
category: 'testing',
|
||||
description: 'Run all test suites across the monorepo',
|
||||
usage: 'Execute to verify functionality. Should pass before commits.',
|
||||
importance: 'high',
|
||||
healthcheck: true,
|
||||
},
|
||||
'supabase:web:start': {
|
||||
category: 'database',
|
||||
description: 'Start local Supabase instance for development',
|
||||
usage: 'Required for local development with database access.',
|
||||
importance: 'critical',
|
||||
},
|
||||
'supabase:web:stop': {
|
||||
category: 'database',
|
||||
description: 'Stop the local Supabase instance',
|
||||
usage: 'Use when done developing to free up resources.',
|
||||
importance: 'medium',
|
||||
},
|
||||
'supabase:web:reset': {
|
||||
category: 'database',
|
||||
description: 'Reset local database to latest schema and seed data',
|
||||
usage: 'Use when database state is corrupted or needs fresh start.',
|
||||
importance: 'high',
|
||||
},
|
||||
'supabase:web:typegen': {
|
||||
category: 'database',
|
||||
description: 'Generate TypeScript types from Supabase database schema',
|
||||
usage: 'Run after database schema changes to update types.',
|
||||
importance: 'high',
|
||||
},
|
||||
'supabase:web:test': {
|
||||
category: 'testing',
|
||||
description: 'Run Supabase-specific tests',
|
||||
usage: 'Test database functions, RLS policies, and migrations.',
|
||||
importance: 'high',
|
||||
},
|
||||
clean: {
|
||||
category: 'maintenance',
|
||||
description: 'Remove all generated files and dependencies',
|
||||
usage:
|
||||
'Use when build artifacts are corrupted. Requires reinstall after.',
|
||||
importance: 'medium',
|
||||
},
|
||||
'clean:workspaces': {
|
||||
category: 'maintenance',
|
||||
description: 'Clean all workspace packages using Turbo',
|
||||
usage: 'Lighter cleanup that preserves node_modules.',
|
||||
importance: 'medium',
|
||||
},
|
||||
'stripe:listen': {
|
||||
category: 'development',
|
||||
description: 'Start Stripe webhook listener for local development',
|
||||
usage: 'Required when testing payment workflows locally.',
|
||||
importance: 'medium',
|
||||
},
|
||||
'env:generate': {
|
||||
category: 'environment',
|
||||
description: 'Generate environment variable templates',
|
||||
usage: 'Creates .env templates for new environments.',
|
||||
importance: 'low',
|
||||
},
|
||||
'env:validate': {
|
||||
category: 'environment',
|
||||
description: 'Validate environment variables against schema',
|
||||
usage: 'Ensures all required environment variables are properly set.',
|
||||
importance: 'medium',
|
||||
},
|
||||
update: {
|
||||
category: 'maintenance',
|
||||
description: 'Update all dependencies across the monorepo',
|
||||
usage: 'Keep dependencies current. Test thoroughly after updating.',
|
||||
importance: 'low',
|
||||
},
|
||||
'syncpack:list': {
|
||||
category: 'maintenance',
|
||||
description: 'List dependency version mismatches across packages',
|
||||
usage: 'Identify inconsistent package versions in monorepo.',
|
||||
importance: 'low',
|
||||
},
|
||||
'syncpack:fix': {
|
||||
category: 'maintenance',
|
||||
description: 'Fix dependency version mismatches across packages',
|
||||
usage: 'Automatically align package versions across workspaces.',
|
||||
importance: 'low',
|
||||
},
|
||||
};
|
||||
|
||||
const scriptInfo = scriptDescriptions[scriptName] || {
|
||||
category: 'maintenance' as const,
|
||||
description: `Custom script: ${scriptName}`,
|
||||
usage: 'See package.json for command details.',
|
||||
importance: 'low' as const,
|
||||
};
|
||||
|
||||
return {
|
||||
name: scriptName,
|
||||
command,
|
||||
...scriptInfo,
|
||||
};
|
||||
}
|
||||
|
||||
static getHealthcheckScripts(): ScriptInfo[] {
|
||||
const allScripts = ['typecheck', 'lint', 'lint:fix', 'format:fix', 'test'];
|
||||
|
||||
return allScripts.map((scriptName) =>
|
||||
this.getScriptInfo(scriptName, `[healthcheck] ${scriptName}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerScriptsTools(server: McpServer) {
|
||||
createGetScriptsTool(server);
|
||||
createGetScriptDetailsTool(server);
|
||||
createGetHealthcheckScriptsTool(server);
|
||||
}
|
||||
|
||||
function createGetScriptsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_scripts',
|
||||
'Get all available npm/pnpm scripts with descriptions and usage guidance',
|
||||
async () => {
|
||||
const scripts = await ScriptsTool.getScripts();
|
||||
|
||||
const scriptsList = scripts
|
||||
.map((script) => {
|
||||
const healthcheck = script.healthcheck ? ' [HEALTHCHECK]' : '';
|
||||
return `${script.name} (${script.category})${healthcheck}: ${script.description}\n Usage: ${script.usage}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Available Scripts (sorted by importance):\n\n${scriptsList}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetScriptDetailsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_script_details',
|
||||
'Get detailed information about a specific script',
|
||||
{
|
||||
state: z.object({
|
||||
scriptName: z.string(),
|
||||
}),
|
||||
},
|
||||
async ({ state }) => {
|
||||
const script = await ScriptsTool.getScriptDetails(state.scriptName);
|
||||
|
||||
const healthcheck = script.healthcheck
|
||||
? '\n<> HEALTHCHECK SCRIPT: This script should be run after writing code to ensure quality.'
|
||||
: '';
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Script: ${script.name}
|
||||
Command: ${script.command}
|
||||
Category: ${script.category}
|
||||
Importance: ${script.importance}
|
||||
Description: ${script.description}
|
||||
Usage: ${script.usage}${healthcheck}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGetHealthcheckScriptsTool(server: McpServer) {
|
||||
return server.tool(
|
||||
'get_healthcheck_scripts',
|
||||
'Get critical scripts that should be run after writing code (typecheck, lint, format, test)',
|
||||
async () => {
|
||||
const scripts = await ScriptsTool.getScripts();
|
||||
const healthcheckScripts = scripts.filter((script) => script.healthcheck);
|
||||
|
||||
const scriptsList = healthcheckScripts
|
||||
.map((script) => `pnpm ${script.name}: ${script.usage}`)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `<<3C> CODE HEALTHCHECK SCRIPTS
|
||||
|
||||
These scripts MUST be run after writing code to ensure quality:
|
||||
|
||||
${scriptsList}
|
||||
|
||||
<EFBFBD> IMPORTANT: Always run these scripts before considering your work complete. They catch type errors, code quality issues, and ensure consistent formatting.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
391
packages/mcp-server/test.ts
Normal file
391
packages/mcp-server/test.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { ComponentsTool } from './src/tools/components';
|
||||
import { DatabaseTool } from './src/tools/database';
|
||||
import { MigrationsTool } from './src/tools/migrations';
|
||||
import { ScriptsTool } from './src/tools/scripts';
|
||||
|
||||
console.log('=== Testing MigrationsTool ===');
|
||||
console.log(await MigrationsTool.GetMigrations());
|
||||
|
||||
console.log(
|
||||
await MigrationsTool.getMigrationContent('20240319163440_roles-seed.sql'),
|
||||
);
|
||||
|
||||
console.log('\n=== Testing ComponentsTool ===');
|
||||
|
||||
console.log('\n--- Getting all components ---');
|
||||
const components = await ComponentsTool.getComponents();
|
||||
console.log(`Found ${components.length} components:`);
|
||||
components.slice(0, 5).forEach((component) => {
|
||||
console.log(
|
||||
`- ${component.name} (${component.category}): ${component.description}`,
|
||||
);
|
||||
});
|
||||
console.log('...');
|
||||
|
||||
console.log('\n--- Testing component content retrieval ---');
|
||||
try {
|
||||
const buttonContent = await ComponentsTool.getComponentContent('button');
|
||||
console.log('Button component content length:', buttonContent.length);
|
||||
console.log('First 200 characters:', buttonContent.substring(0, 200));
|
||||
} catch (error) {
|
||||
console.error('Error getting button component:', error);
|
||||
}
|
||||
|
||||
console.log('\n--- Testing component filtering by category ---');
|
||||
const shadcnComponents = components.filter((c) => c.category === 'shadcn');
|
||||
const makerkitComponents = components.filter((c) => c.category === 'makerkit');
|
||||
const utilsComponents = components.filter((c) => c.category === 'utils');
|
||||
|
||||
console.log(`Shadcn components: ${shadcnComponents.length}`);
|
||||
console.log(`Makerkit components: ${makerkitComponents.length}`);
|
||||
console.log(`Utils components: ${utilsComponents.length}`);
|
||||
|
||||
console.log('\n--- Sample components by category ---');
|
||||
console.log(
|
||||
'Shadcn:',
|
||||
shadcnComponents
|
||||
.slice(0, 3)
|
||||
.map((c) => c.name)
|
||||
.join(', '),
|
||||
);
|
||||
console.log(
|
||||
'Makerkit:',
|
||||
makerkitComponents
|
||||
.slice(0, 3)
|
||||
.map((c) => c.name)
|
||||
.join(', '),
|
||||
);
|
||||
console.log('Utils:', utilsComponents.map((c) => c.name).join(', '));
|
||||
|
||||
console.log('\n--- Testing error handling ---');
|
||||
try {
|
||||
await ComponentsTool.getComponentContent('non-existent-component');
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Expected error for non-existent component:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n=== Testing ScriptsTool ===');
|
||||
|
||||
console.log('\n--- Getting all scripts ---');
|
||||
const scripts = await ScriptsTool.getScripts();
|
||||
console.log(`Found ${scripts.length} scripts:`);
|
||||
|
||||
console.log('\n--- Critical and High importance scripts ---');
|
||||
const importantScripts = scripts.filter(
|
||||
(s) => s.importance === 'critical' || s.importance === 'high',
|
||||
);
|
||||
importantScripts.forEach((script) => {
|
||||
const healthcheck = script.healthcheck ? ' [HEALTHCHECK]' : '';
|
||||
console.log(
|
||||
`- ${script.name} (${script.importance})${healthcheck}: ${script.description}`,
|
||||
);
|
||||
});
|
||||
|
||||
console.log('\n--- Healthcheck scripts (code quality) ---');
|
||||
const healthcheckScripts = scripts.filter((s) => s.healthcheck);
|
||||
console.log('Scripts that should be run after writing code:');
|
||||
healthcheckScripts.forEach((script) => {
|
||||
console.log(`- pnpm ${script.name}: ${script.usage}`);
|
||||
});
|
||||
|
||||
console.log('\n--- Scripts by category ---');
|
||||
const categories = [...new Set(scripts.map((s) => s.category))];
|
||||
categories.forEach((category) => {
|
||||
const categoryScripts = scripts.filter((s) => s.category === category);
|
||||
console.log(`${category}: ${categoryScripts.map((s) => s.name).join(', ')}`);
|
||||
});
|
||||
|
||||
console.log('\n--- Testing script details ---');
|
||||
try {
|
||||
const typecheckDetails = await ScriptsTool.getScriptDetails('typecheck');
|
||||
console.log('Typecheck script details:');
|
||||
console.log(` Command: ${typecheckDetails.command}`);
|
||||
console.log(` Importance: ${typecheckDetails.importance}`);
|
||||
console.log(` Healthcheck: ${typecheckDetails.healthcheck}`);
|
||||
console.log(` Usage: ${typecheckDetails.usage}`);
|
||||
} catch (error) {
|
||||
console.error('Error getting typecheck details:', error);
|
||||
}
|
||||
|
||||
console.log('\n--- Testing error handling for scripts ---');
|
||||
try {
|
||||
await ScriptsTool.getScriptDetails('non-existent-script');
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Expected error for non-existent script:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n=== Testing New ComponentsTool Features ===');
|
||||
|
||||
console.log('\n--- Testing component search ---');
|
||||
const buttonSearchResults = await ComponentsTool.searchComponents('button');
|
||||
console.log(`Search for "button": ${buttonSearchResults.length} results`);
|
||||
buttonSearchResults.forEach((component) => {
|
||||
console.log(` - ${component.name}: ${component.description}`);
|
||||
});
|
||||
|
||||
console.log('\n--- Testing search by category ---');
|
||||
const shadcnSearchResults = await ComponentsTool.searchComponents('shadcn');
|
||||
console.log(
|
||||
`Search for "shadcn": ${shadcnSearchResults.length} results (showing first 3)`,
|
||||
);
|
||||
shadcnSearchResults.slice(0, 3).forEach((component) => {
|
||||
console.log(` - ${component.name}`);
|
||||
});
|
||||
|
||||
console.log('\n--- Testing search by description keyword ---');
|
||||
const formSearchResults = await ComponentsTool.searchComponents('form');
|
||||
console.log(`Search for "form": ${formSearchResults.length} results`);
|
||||
formSearchResults.forEach((component) => {
|
||||
console.log(` - ${component.name}: ${component.description}`);
|
||||
});
|
||||
|
||||
console.log('\n--- Testing component props extraction ---');
|
||||
try {
|
||||
console.log('\n--- Button component props ---');
|
||||
const buttonProps = await ComponentsTool.getComponentProps('button');
|
||||
console.log(`Component: ${buttonProps.componentName}`);
|
||||
console.log(`Interfaces: ${buttonProps.interfaces.join(', ')}`);
|
||||
console.log(`Props (${buttonProps.props.length}):`);
|
||||
buttonProps.props.forEach((prop) => {
|
||||
const optional = prop.optional ? '?' : '';
|
||||
console.log(` - ${prop.name}${optional}: ${prop.type}`);
|
||||
});
|
||||
if (buttonProps.variants) {
|
||||
console.log('Variants:');
|
||||
Object.entries(buttonProps.variants).forEach(([variantName, options]) => {
|
||||
console.log(` - ${variantName}: ${options.join(' | ')}`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting button props:', error);
|
||||
}
|
||||
|
||||
console.log('\n--- Testing simpler component props ---');
|
||||
try {
|
||||
const ifProps = await ComponentsTool.getComponentProps('if');
|
||||
console.log(`Component: ${ifProps.componentName}`);
|
||||
console.log(`Interfaces: ${ifProps.interfaces.join(', ')}`);
|
||||
console.log(`Props count: ${ifProps.props.length}`);
|
||||
if (ifProps.props.length > 0) {
|
||||
ifProps.props.forEach((prop) => {
|
||||
const optional = prop.optional ? '?' : '';
|
||||
console.log(` - ${prop.name}${optional}: ${prop.type}`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting if component props:', error);
|
||||
}
|
||||
|
||||
console.log('\n--- Testing search with no results ---');
|
||||
const noResults = await ComponentsTool.searchComponents('xyz123nonexistent');
|
||||
console.log(`Search for non-existent: ${noResults.length} results`);
|
||||
|
||||
console.log('\n--- Testing props extraction error handling ---');
|
||||
try {
|
||||
await ComponentsTool.getComponentProps('non-existent-component');
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Expected error for non-existent component props:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n=== Testing DatabaseTool ===');
|
||||
|
||||
console.log('\n--- Getting schema files ---');
|
||||
const schemaFiles = await DatabaseTool.getSchemaFiles();
|
||||
console.log(`Found ${schemaFiles.length} schema files:`);
|
||||
schemaFiles.slice(0, 5).forEach((file) => {
|
||||
console.log(` - ${file.name}: ${file.section}`);
|
||||
});
|
||||
|
||||
console.log('\n--- Getting database functions ---');
|
||||
const dbFunctions = await DatabaseTool.getFunctions();
|
||||
console.log(`Found ${dbFunctions.length} database functions:`);
|
||||
dbFunctions.forEach((func) => {
|
||||
const security = func.securityLevel === 'definer' ? ' [DEFINER]' : '';
|
||||
console.log(` - ${func.name}${security}: ${func.purpose}`);
|
||||
});
|
||||
|
||||
console.log('\n--- Testing function search ---');
|
||||
const authFunctions = await DatabaseTool.searchFunctions('auth');
|
||||
console.log(`Functions related to "auth": ${authFunctions.length}`);
|
||||
authFunctions.forEach((func) => {
|
||||
console.log(` - ${func.name}: ${func.purpose}`);
|
||||
});
|
||||
|
||||
console.log('\n--- Testing function search by security ---');
|
||||
const definerFunctions = await DatabaseTool.searchFunctions('definer');
|
||||
console.log(`Functions with security definer: ${definerFunctions.length}`);
|
||||
definerFunctions.forEach((func) => {
|
||||
console.log(` - ${func.name}: ${func.purpose}`);
|
||||
});
|
||||
|
||||
console.log('\n--- Testing function details ---');
|
||||
if (dbFunctions.length > 0) {
|
||||
try {
|
||||
const firstFunction = dbFunctions[0];
|
||||
if (firstFunction) {
|
||||
const functionDetails = await DatabaseTool.getFunctionDetails(
|
||||
firstFunction.name,
|
||||
);
|
||||
console.log(`Details for ${functionDetails.name}:`);
|
||||
console.log(` Purpose: ${functionDetails.purpose}`);
|
||||
console.log(` Return Type: ${functionDetails.returnType}`);
|
||||
console.log(` Security: ${functionDetails.securityLevel}`);
|
||||
console.log(` Parameters: ${functionDetails.parameters.length}`);
|
||||
functionDetails.parameters.forEach((param) => {
|
||||
const defaultVal = param.defaultValue
|
||||
? ` (default: ${param.defaultValue})`
|
||||
: '';
|
||||
console.log(` - ${param.name}: ${param.type}${defaultVal}`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting function details:', error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n--- Testing function search with no results ---');
|
||||
const noFunctionResults =
|
||||
await DatabaseTool.searchFunctions('xyz123nonexistent');
|
||||
console.log(
|
||||
`Search for non-existent function: ${noFunctionResults.length} results`,
|
||||
);
|
||||
|
||||
console.log('\n--- Testing function details error handling ---');
|
||||
try {
|
||||
await DatabaseTool.getFunctionDetails('non-existent-function');
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Expected error for non-existent function:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n=== Testing Enhanced DatabaseTool Features ===');
|
||||
|
||||
console.log('\n--- Testing direct schema content access ---');
|
||||
try {
|
||||
const accountsSchemaContent =
|
||||
await DatabaseTool.getSchemaContent('03-accounts.sql');
|
||||
console.log('Accounts schema content length:', accountsSchemaContent.length);
|
||||
console.log('First 200 characters:', accountsSchemaContent.substring(0, 200));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error getting accounts schema content:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n--- Testing schema search by topic ---');
|
||||
const authSchemas = await DatabaseTool.getSchemasByTopic('auth');
|
||||
console.log(`Schemas related to "auth": ${authSchemas.length}`);
|
||||
authSchemas.forEach((schema) => {
|
||||
console.log(` - ${schema.name} (${schema.topic}): ${schema.section}`);
|
||||
if (schema.functions.length > 0) {
|
||||
console.log(` Functions: ${schema.functions.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n--- Testing schema search by topic - billing ---');
|
||||
const billingSchemas = await DatabaseTool.getSchemasByTopic('billing');
|
||||
console.log(`Schemas related to "billing": ${billingSchemas.length}`);
|
||||
billingSchemas.forEach((schema) => {
|
||||
console.log(` - ${schema.name}: ${schema.description}`);
|
||||
if (schema.tables.length > 0) {
|
||||
console.log(` Tables: ${schema.tables.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n--- Testing schema search by topic - accounts ---');
|
||||
const accountSchemas = await DatabaseTool.getSchemasByTopic('accounts');
|
||||
console.log(`Schemas related to "accounts": ${accountSchemas.length}`);
|
||||
accountSchemas.forEach((schema) => {
|
||||
console.log(` - ${schema.name}: ${schema.description}`);
|
||||
if (schema.dependencies.length > 0) {
|
||||
console.log(` Dependencies: ${schema.dependencies.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n--- Testing schema by section lookup ---');
|
||||
try {
|
||||
const accountsSection = await DatabaseTool.getSchemaBySection('Accounts');
|
||||
if (accountsSection) {
|
||||
console.log(`Found section: ${accountsSection.section}`);
|
||||
console.log(`File: ${accountsSection.name}`);
|
||||
console.log(`Topic: ${accountsSection.topic}`);
|
||||
console.log(`Tables: ${accountsSection.tables.join(', ')}`);
|
||||
console.log(`Last modified: ${accountsSection.lastModified.toISOString()}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting accounts section:', error);
|
||||
}
|
||||
|
||||
console.log('\n--- Testing enhanced schema metadata ---');
|
||||
const enhancedSchemas = await DatabaseTool.getSchemaFiles();
|
||||
console.log(`Total schemas with metadata: ${enhancedSchemas.length}`);
|
||||
|
||||
// Show schemas with the most tables
|
||||
const schemasWithTables = enhancedSchemas.filter((s) => s.tables.length > 0);
|
||||
console.log(`Schemas with tables: ${schemasWithTables.length}`);
|
||||
schemasWithTables.slice(0, 3).forEach((schema) => {
|
||||
console.log(
|
||||
` - ${schema.name}: ${schema.tables.length} tables (${schema.tables.join(', ')})`,
|
||||
);
|
||||
});
|
||||
|
||||
// Show schemas with functions
|
||||
const schemasWithFunctions = enhancedSchemas.filter(
|
||||
(s) => s.functions.length > 0,
|
||||
);
|
||||
console.log(`Schemas with functions: ${schemasWithFunctions.length}`);
|
||||
schemasWithFunctions.slice(0, 3).forEach((schema) => {
|
||||
console.log(
|
||||
` - ${schema.name}: ${schema.functions.length} functions (${schema.functions.join(', ')})`,
|
||||
);
|
||||
});
|
||||
|
||||
// Show topic distribution
|
||||
const topicCounts = enhancedSchemas.reduce(
|
||||
(acc, schema) => {
|
||||
acc[schema.topic] = (acc[schema.topic] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
console.log('\n--- Topic distribution ---');
|
||||
Object.entries(topicCounts).forEach(([topic, count]) => {
|
||||
console.log(` - ${topic}: ${count} files`);
|
||||
});
|
||||
|
||||
console.log('\n--- Testing error handling for enhanced features ---');
|
||||
try {
|
||||
await DatabaseTool.getSchemaContent('non-existent-schema.sql');
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Expected error for non-existent schema:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const nonExistentSection =
|
||||
await DatabaseTool.getSchemaBySection('NonExistentSection');
|
||||
console.log('Non-existent section result:', nonExistentSection);
|
||||
} catch (error) {
|
||||
console.error('Unexpected error for non-existent section:', error);
|
||||
}
|
||||
|
||||
const emptyTopicResults =
|
||||
await DatabaseTool.getSchemasByTopic('xyz123nonexistent');
|
||||
console.log(
|
||||
`Search for non-existent topic: ${emptyTopicResults.length} results`,
|
||||
);
|
||||
14
packages/mcp-server/tsconfig.json
Normal file
14
packages/mcp-server/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
|
||||
"outDir": "./build",
|
||||
"noEmit": false,
|
||||
"strict": false,
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"files": ["src/index.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
"@kit/sentry": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react": "19.1.13",
|
||||
"react": "19.1.1",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react": "19.1.13",
|
||||
"react": "19.1.1"
|
||||
},
|
||||
"typesVersions": {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"./config/server": "./src/sentry.client.server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/nextjs": "^10.10.0",
|
||||
"@sentry/nextjs": "^10.11.0",
|
||||
"import-in-the-middle": "1.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -24,7 +24,7 @@
|
||||
"@kit/monitoring-core": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react": "19.1.13",
|
||||
"react": "19.1.1"
|
||||
},
|
||||
"typesVersions": {
|
||||
|
||||
435
packages/next/AGENTS.md
Normal file
435
packages/next/AGENTS.md
Normal file
@@ -0,0 +1,435 @@
|
||||
# Next.js Utilities Instructions
|
||||
|
||||
This file contains instructions for working with Next.js utilities including server actions and route handlers.
|
||||
|
||||
## Server Actions Implementation
|
||||
|
||||
Always use `enhanceAction` from `@packages/next/src/actions/index.ts`:
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Define your schema
|
||||
const CreateNoteSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
content: z.string().min(1, 'Content is required'),
|
||||
accountId: z.string().uuid('Invalid account ID'),
|
||||
});
|
||||
|
||||
export const createNoteAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
// data is automatically validated against the schema
|
||||
// user is automatically authenticated if auth: true
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: note, error } = await client
|
||||
.from('notes')
|
||||
.insert({
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
account_id: data.accountId,
|
||||
user_id: user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { success: true, note };
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication
|
||||
schema: CreateNoteSchema, // Validate input with Zod
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Server Action Examples
|
||||
|
||||
- Team billing: `@apps/web/app/home/[account]/billing/_lib/server/server-actions.ts`
|
||||
- Personal settings: `@apps/web/app/home/(user)/settings/_lib/server/server-actions.ts`
|
||||
|
||||
### Server Action Options
|
||||
|
||||
```typescript
|
||||
export const myAction = enhanceAction(
|
||||
async function (data, user, requestData) {
|
||||
// data: validated input data
|
||||
// user: authenticated user (if auth: true)
|
||||
// requestData: additional request information
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication (default: false)
|
||||
schema: MySchema, // Zod schema for validation (optional)
|
||||
// Additional options available
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Route Handlers (API Routes)
|
||||
|
||||
Use `enhanceRouteHandler` from `@packages/next/src/routes/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Define your schema
|
||||
const CreateItemSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user, request }) {
|
||||
// body is validated against schema
|
||||
// user is available if auth: true
|
||||
// request is the original NextRequest
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data, error } = await client
|
||||
.from('items')
|
||||
.insert({
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
user_id: user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create item' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data });
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication
|
||||
schema: CreateItemSchema, // Validate request body
|
||||
},
|
||||
);
|
||||
|
||||
export const GET = enhanceRouteHandler(
|
||||
async function ({ user, request }) {
|
||||
const url = new URL(request.url);
|
||||
const limit = url.searchParams.get('limit') || '10';
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data, error } = await client
|
||||
.from('items')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.limit(parseInt(limit));
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch items' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ data });
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
// No schema needed for GET requests
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Route Handler Options
|
||||
|
||||
```typescript
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user, request }) {
|
||||
// Handler function
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication (default: false)
|
||||
schema: MySchema, // Zod schema for body validation (optional)
|
||||
// Additional options available
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Error Handling Patterns
|
||||
|
||||
### Server Actions with Error Handling
|
||||
|
||||
```typescript
|
||||
export const createNoteAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'create-note', userId: user.id };
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Creating note');
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: note, error } = await client
|
||||
.from('notes')
|
||||
.insert({
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
user_id: user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
logger.error({ ...ctx, error }, 'Failed to create note');
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info({ ...ctx, noteId: note.id }, 'Note created successfully');
|
||||
|
||||
return { success: true, note };
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'Create note action failed');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateNoteSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Route Handler with Error Handling
|
||||
|
||||
```typescript
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user }) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'api-create-item', userId: user.id };
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Processing API request');
|
||||
|
||||
// Process request
|
||||
const result = await processRequest(body, user);
|
||||
|
||||
logger.info({ ...ctx, result }, 'API request successful');
|
||||
|
||||
return NextResponse.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'API request failed');
|
||||
|
||||
if (error.message.includes('validation')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input data' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateItemSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Client-Side Integration
|
||||
|
||||
### Using Server Actions in Components
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
import { createNoteAction } from './actions';
|
||||
import { CreateNoteSchema } from './schemas';
|
||||
|
||||
function CreateNoteForm() {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateNoteSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
content: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await createNoteAction(data);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Note created successfully!');
|
||||
form.reset();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to create note');
|
||||
console.error('Create note error:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
{/* Form fields */}
|
||||
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Creating...' : 'Create Note'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Using Route Handlers with Fetch
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
async function createItem(data: CreateItemInput) {
|
||||
const response = await fetch('/api/items', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Failed to create item');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Usage in component
|
||||
const handleCreateItem = async (data) => {
|
||||
try {
|
||||
const result = await createItem(data);
|
||||
toast.success('Item created successfully!');
|
||||
return result;
|
||||
} catch (error) {
|
||||
toast.error('Failed to create item');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Input Validation
|
||||
|
||||
Always use Zod schemas for input validation:
|
||||
|
||||
```typescript
|
||||
// Define strict schemas
|
||||
const UpdateUserSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
age: z.number().int().min(18).max(120),
|
||||
});
|
||||
|
||||
// Server action with validation
|
||||
export const updateUserAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
// data is guaranteed to match the schema
|
||||
// Additional business logic validation can go here
|
||||
|
||||
if (data.email !== user.email) {
|
||||
// Check if email change is allowed
|
||||
const canChangeEmail = await checkEmailChangePermission(user);
|
||||
if (!canChangeEmail) {
|
||||
throw new Error('Email change not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
// Update user
|
||||
return await updateUser(user.id, data);
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: UpdateUserSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Authorization Checks
|
||||
|
||||
```typescript
|
||||
export const deleteAccountAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
// Verify user owns the account
|
||||
const { data: account, error } = await client
|
||||
.from('accounts')
|
||||
.select('id, primary_owner_user_id')
|
||||
.eq('id', data.accountId)
|
||||
.single();
|
||||
|
||||
if (error || !account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
if (account.primary_owner_user_id !== user.id) {
|
||||
throw new Error('Only account owners can delete accounts');
|
||||
}
|
||||
|
||||
// Additional checks
|
||||
const hasActiveSubscription = await client
|
||||
.rpc('has_active_subscription', { account_id: data.accountId });
|
||||
|
||||
if (hasActiveSubscription) {
|
||||
throw new Error('Cannot delete account with active subscription');
|
||||
}
|
||||
|
||||
// Proceed with deletion
|
||||
await deleteAccount(data.accountId);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: DeleteAccountSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Middleware Integration
|
||||
|
||||
The `enhanceAction` and `enhanceRouteHandler` utilities integrate with the application middleware for:
|
||||
|
||||
- CSRF protection
|
||||
- Authentication verification
|
||||
- Request logging
|
||||
- Error handling
|
||||
- Input validation
|
||||
|
||||
This ensures consistent security and monitoring across all server actions and API routes.
|
||||
435
packages/next/CLAUDE.md
Normal file
435
packages/next/CLAUDE.md
Normal file
@@ -0,0 +1,435 @@
|
||||
# Next.js Utilities Instructions
|
||||
|
||||
This file contains instructions for working with Next.js utilities including server actions and route handlers.
|
||||
|
||||
## Server Actions Implementation
|
||||
|
||||
Always use `enhanceAction` from `@packages/next/src/actions/index.ts`:
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Define your schema
|
||||
const CreateNoteSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
content: z.string().min(1, 'Content is required'),
|
||||
accountId: z.string().uuid('Invalid account ID'),
|
||||
});
|
||||
|
||||
export const createNoteAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
// data is automatically validated against the schema
|
||||
// user is automatically authenticated if auth: true
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: note, error } = await client
|
||||
.from('notes')
|
||||
.insert({
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
account_id: data.accountId,
|
||||
user_id: user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { success: true, note };
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication
|
||||
schema: CreateNoteSchema, // Validate input with Zod
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Server Action Examples
|
||||
|
||||
- Team billing: `@apps/web/app/home/[account]/billing/_lib/server/server-actions.ts`
|
||||
- Personal settings: `@apps/web/app/home/(user)/settings/_lib/server/server-actions.ts`
|
||||
|
||||
### Server Action Options
|
||||
|
||||
```typescript
|
||||
export const myAction = enhanceAction(
|
||||
async function (data, user, requestData) {
|
||||
// data: validated input data
|
||||
// user: authenticated user (if auth: true)
|
||||
// requestData: additional request information
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication (default: false)
|
||||
schema: MySchema, // Zod schema for validation (optional)
|
||||
// Additional options available
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Route Handlers (API Routes)
|
||||
|
||||
Use `enhanceRouteHandler` from `@packages/next/src/routes/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Define your schema
|
||||
const CreateItemSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user, request }) {
|
||||
// body is validated against schema
|
||||
// user is available if auth: true
|
||||
// request is the original NextRequest
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data, error } = await client
|
||||
.from('items')
|
||||
.insert({
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
user_id: user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create item' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data });
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication
|
||||
schema: CreateItemSchema, // Validate request body
|
||||
},
|
||||
);
|
||||
|
||||
export const GET = enhanceRouteHandler(
|
||||
async function ({ user, request }) {
|
||||
const url = new URL(request.url);
|
||||
const limit = url.searchParams.get('limit') || '10';
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data, error } = await client
|
||||
.from('items')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.limit(parseInt(limit));
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch items' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ data });
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
// No schema needed for GET requests
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Route Handler Options
|
||||
|
||||
```typescript
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user, request }) {
|
||||
// Handler function
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication (default: false)
|
||||
schema: MySchema, // Zod schema for body validation (optional)
|
||||
// Additional options available
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Error Handling Patterns
|
||||
|
||||
### Server Actions with Error Handling
|
||||
|
||||
```typescript
|
||||
export const createNoteAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'create-note', userId: user.id };
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Creating note');
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: note, error } = await client
|
||||
.from('notes')
|
||||
.insert({
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
user_id: user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
logger.error({ ...ctx, error }, 'Failed to create note');
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info({ ...ctx, noteId: note.id }, 'Note created successfully');
|
||||
|
||||
return { success: true, note };
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'Create note action failed');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateNoteSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Route Handler with Error Handling
|
||||
|
||||
```typescript
|
||||
export const POST = enhanceRouteHandler(
|
||||
async function ({ body, user }) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'api-create-item', userId: user.id };
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Processing API request');
|
||||
|
||||
// Process request
|
||||
const result = await processRequest(body, user);
|
||||
|
||||
logger.info({ ...ctx, result }, 'API request successful');
|
||||
|
||||
return NextResponse.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'API request failed');
|
||||
|
||||
if (error.message.includes('validation')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input data' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateItemSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Client-Side Integration
|
||||
|
||||
### Using Server Actions in Components
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
import { createNoteAction } from './actions';
|
||||
import { CreateNoteSchema } from './schemas';
|
||||
|
||||
function CreateNoteForm() {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateNoteSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
content: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await createNoteAction(data);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Note created successfully!');
|
||||
form.reset();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to create note');
|
||||
console.error('Create note error:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
{/* Form fields */}
|
||||
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Creating...' : 'Create Note'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Using Route Handlers with Fetch
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
async function createItem(data: CreateItemInput) {
|
||||
const response = await fetch('/api/items', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Failed to create item');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Usage in component
|
||||
const handleCreateItem = async (data) => {
|
||||
try {
|
||||
const result = await createItem(data);
|
||||
toast.success('Item created successfully!');
|
||||
return result;
|
||||
} catch (error) {
|
||||
toast.error('Failed to create item');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Input Validation
|
||||
|
||||
Always use Zod schemas for input validation:
|
||||
|
||||
```typescript
|
||||
// Define strict schemas
|
||||
const UpdateUserSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
age: z.number().int().min(18).max(120),
|
||||
});
|
||||
|
||||
// Server action with validation
|
||||
export const updateUserAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
// data is guaranteed to match the schema
|
||||
// Additional business logic validation can go here
|
||||
|
||||
if (data.email !== user.email) {
|
||||
// Check if email change is allowed
|
||||
const canChangeEmail = await checkEmailChangePermission(user);
|
||||
if (!canChangeEmail) {
|
||||
throw new Error('Email change not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
// Update user
|
||||
return await updateUser(user.id, data);
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: UpdateUserSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Authorization Checks
|
||||
|
||||
```typescript
|
||||
export const deleteAccountAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
// Verify user owns the account
|
||||
const { data: account, error } = await client
|
||||
.from('accounts')
|
||||
.select('id, primary_owner_user_id')
|
||||
.eq('id', data.accountId)
|
||||
.single();
|
||||
|
||||
if (error || !account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
if (account.primary_owner_user_id !== user.id) {
|
||||
throw new Error('Only account owners can delete accounts');
|
||||
}
|
||||
|
||||
// Additional checks
|
||||
const hasActiveSubscription = await client
|
||||
.rpc('has_active_subscription', { account_id: data.accountId });
|
||||
|
||||
if (hasActiveSubscription) {
|
||||
throw new Error('Cannot delete account with active subscription');
|
||||
}
|
||||
|
||||
// Proceed with deletion
|
||||
await deleteAccount(data.accountId);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: DeleteAccountSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Middleware Integration
|
||||
|
||||
The `enhanceAction` and `enhanceRouteHandler` utilities integrate with the application middleware for:
|
||||
|
||||
- CSRF protection
|
||||
- Authentication verification
|
||||
- Request logging
|
||||
- Error handling
|
||||
- Input validation
|
||||
|
||||
This ensures consistent security and monitoring across all server actions and API routes.
|
||||
@@ -20,8 +20,8 @@
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"next": "15.5.2",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"next": "15.5.3",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
"typesVersions": {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@kit/email-templates": "workspace:*",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/mailers": "workspace:*",
|
||||
@@ -25,8 +25,8 @@
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@types/react": "19.1.12",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@types/react": "19.1.13",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/react": "19.1.12"
|
||||
"@types/react": "19.1.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"pino": "^9.9.4"
|
||||
"pino": "^9.9.5"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
312
packages/supabase/AGENTS.md
Normal file
312
packages/supabase/AGENTS.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# Database & Authentication Instructions
|
||||
|
||||
This file contains instructions for working with Supabase, database security, and authentication.
|
||||
|
||||
## Database Security Guidelines ⚠️
|
||||
|
||||
**Critical Security Guidelines - Read Carefully!**
|
||||
|
||||
### Database Security Fundamentals
|
||||
|
||||
- **Always enable RLS** on new tables unless explicitly instructed otherwise
|
||||
- **NEVER use SECURITY DEFINER functions** without explicit access controls - they bypass RLS entirely
|
||||
- **Always use security_invoker=true for views** to maintain proper access control
|
||||
- **Storage buckets MUST validate access** using account_id in the path structure. See `apps/web/supabase/schemas/16-storage.sql` for proper implementation.
|
||||
- **Use locks if required**: Database locks prevent race conditions and timing attacks in concurrent operations. Make sure to take these into account for all database operations.
|
||||
|
||||
### Security Definer Function - Dangerous Pattern ❌
|
||||
|
||||
```sql
|
||||
-- NEVER DO THIS - Allows any authenticated user to call function
|
||||
CREATE OR REPLACE FUNCTION public.dangerous_function()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER AS $
|
||||
BEGIN
|
||||
-- This bypasses all RLS policies!
|
||||
DELETE FROM sensitive_table; -- Anyone can call this!
|
||||
END;
|
||||
$;
|
||||
GRANT EXECUTE ON FUNCTION public.dangerous_function() TO authenticated;
|
||||
```
|
||||
|
||||
### Security Definer Function - Safe Pattern ✅
|
||||
|
||||
```sql
|
||||
-- ONLY use SECURITY DEFINER with explicit access validation
|
||||
CREATE OR REPLACE FUNCTION public.safe_admin_function(target_account_id uuid)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = '' AS $
|
||||
BEGIN
|
||||
-- MUST validate caller has permission FIRST
|
||||
IF NOT public.is_account_owner(target_account_id) THEN
|
||||
RAISE EXCEPTION 'Access denied: insufficient permissions';
|
||||
END IF;
|
||||
|
||||
-- Now safe to proceed with elevated privileges
|
||||
-- Your admin operation here
|
||||
END;
|
||||
$;
|
||||
```
|
||||
|
||||
Only grant critical functions to `service_role`:
|
||||
|
||||
```sql
|
||||
grant execute on public.dangerous_function to service_role;
|
||||
```
|
||||
|
||||
## Existing Helper Functions - Use These! 📚
|
||||
|
||||
**DO NOT recreate these functions - they already exist:**
|
||||
|
||||
```sql
|
||||
-- Account Access Control
|
||||
public.has_role_on_account(account_id, role?) -- Check team membership
|
||||
public.has_permission(user_id, account_id, permission) -- Check permissions
|
||||
public.is_account_owner(account_id) -- Verify ownership
|
||||
public.has_active_subscription(account_id) -- Subscription status
|
||||
public.is_team_member(account_id, user_id) -- Direct membership check
|
||||
public.can_action_account_member(target_account_id, target_user_id) -- Member action rights
|
||||
|
||||
-- Administrative Functions
|
||||
public.is_super_admin() -- Super admin check
|
||||
public.is_aal2() -- MFA verification
|
||||
public.is_mfa_compliant() -- MFA compliance
|
||||
|
||||
-- Configuration
|
||||
public.is_set(field_name) -- Feature flag checks
|
||||
```
|
||||
|
||||
Always check `apps/web/supabase/schemas/` before creating new functions!
|
||||
|
||||
## RLS Policy Best Practices ✅
|
||||
|
||||
```sql
|
||||
-- Proper RLS using existing helper functions
|
||||
CREATE POLICY "notes_read" ON public.notes FOR SELECT
|
||||
TO authenticated USING (
|
||||
account_id = (select auth.uid()) OR
|
||||
public.has_role_on_account(account_id)
|
||||
);
|
||||
|
||||
-- For operations requiring specific permissions
|
||||
CREATE POLICY "notes_manage" ON public.notes FOR ALL
|
||||
TO authenticated USING (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
```
|
||||
|
||||
## Schema Management Workflow
|
||||
|
||||
1. Create schemas in `apps/web/supabase/schemas/` as `<number>-<name>.sql`
|
||||
2. After changes: `pnpm supabase:web:stop`
|
||||
3. Run: `pnpm --filter web run supabase:db:diff -f <filename>`
|
||||
4. Restart: `pnpm supabase:web:start` and `pnpm supabase:web:reset`
|
||||
5. Generate types: `pnpm supabase:web:typegen`
|
||||
|
||||
- **Never modify database.types.ts**: Instead, use the Supabase CLI using our package.json scripts to re-generate the types after resetting the DB
|
||||
|
||||
### Key Schema Files
|
||||
|
||||
- Accounts: `apps/web/supabase/schemas/03-accounts.sql`
|
||||
- Memberships: `apps/web/supabase/schemas/05-memberships.sql`
|
||||
- Permissions: `apps/web/supabase/schemas/06-roles-permissions.sql`
|
||||
|
||||
## Type Generation
|
||||
|
||||
```typescript
|
||||
import { Tables } from '@kit/supabase/database';
|
||||
|
||||
type Account = Tables<'accounts'>;
|
||||
```
|
||||
|
||||
Always prefer inferring types from generated Database types.
|
||||
|
||||
## Client Usage Patterns
|
||||
|
||||
### Server Components (Preferred)
|
||||
|
||||
```typescript
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
async function NotesPage() {
|
||||
const client = getSupabaseServerClient();
|
||||
const { data, error } = await client.from('notes').select('*');
|
||||
|
||||
if (error) return <ErrorMessage error={error} />;
|
||||
|
||||
return <NotesList notes={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Client Components
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
function InteractiveNotes() {
|
||||
const supabase = useSupabase();
|
||||
// Use with React Query for optimal data fetching
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Client (Use with Extreme Caution) ⚠️
|
||||
|
||||
```typescript
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
async function adminFunction() {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// CRITICAL: Manual authorization required - bypasses RLS!
|
||||
const currentUser = await getCurrentUser();
|
||||
|
||||
if (!(await isSuperAdmin(currentUser))) {
|
||||
throw new Error('Unauthorized: Admin access required');
|
||||
}
|
||||
|
||||
// Now safe to proceed with admin privileges
|
||||
const { data } = await adminClient.from('table').select('*');
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication Patterns
|
||||
|
||||
### Multi-Factor Authentication
|
||||
|
||||
```typescript
|
||||
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
|
||||
|
||||
const requiresMultiFactorAuthentication =
|
||||
await checkRequiresMultiFactorAuthentication(supabase);
|
||||
|
||||
if (requiresMultiFactorAuthentication) {
|
||||
// Redirect to MFA page
|
||||
}
|
||||
```
|
||||
|
||||
### User Requirements
|
||||
|
||||
```typescript
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const user = await requireUser(client, { verifyMfa: false });
|
||||
```
|
||||
|
||||
## Storage Security
|
||||
|
||||
Storage buckets must validate access using account_id in the path structure:
|
||||
|
||||
```sql
|
||||
-- RLS policies for storage bucket account_image
|
||||
create policy account_image on storage.objects for all using (
|
||||
bucket_id = 'account_image'
|
||||
and (
|
||||
kit.get_storage_filename_as_uuid(name) = auth.uid()
|
||||
or public.has_role_on_account(kit.get_storage_filename_as_uuid(name))
|
||||
)
|
||||
)
|
||||
with check (
|
||||
bucket_id = 'account_image'
|
||||
and (
|
||||
kit.get_storage_filename_as_uuid(name) = auth.uid()
|
||||
or public.has_permission(
|
||||
auth.uid(),
|
||||
kit.get_storage_filename_as_uuid(name),
|
||||
'settings.manage'
|
||||
)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## Common Database Operations
|
||||
|
||||
### Creating Tables with RLS
|
||||
|
||||
```sql
|
||||
-- Create table
|
||||
create table if not exists public.notes (
|
||||
id uuid unique not null default extensions.uuid_generate_v4(),
|
||||
account_id uuid references public.accounts(id) on delete cascade not null,
|
||||
title varchar(255) not null,
|
||||
content text,
|
||||
created_at timestamp with time zone default now(),
|
||||
updated_at timestamp with time zone default now(),
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
-- Enable RLS
|
||||
alter table "public"."notes" enable row level security;
|
||||
|
||||
-- Grant permissions
|
||||
grant select, insert, update, delete on table public.notes to authenticated;
|
||||
|
||||
-- Create RLS policies
|
||||
create policy "notes_read" on public.notes for select
|
||||
to authenticated using (
|
||||
account_id = (select auth.uid()) or
|
||||
public.has_role_on_account(account_id)
|
||||
);
|
||||
|
||||
create policy "notes_write" on public.notes for insert
|
||||
to authenticated with check (
|
||||
account_id = (select auth.uid()) or
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
```
|
||||
|
||||
### Indexes for Performance
|
||||
|
||||
```sql
|
||||
-- Create indexes for common queries
|
||||
create index if not exists ix_notes_account_id on public.notes (account_id);
|
||||
create index if not exists ix_notes_created_at on public.notes (created_at);
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
async function databaseOperation() {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'database-operation', accountId: 'account-123' };
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Starting database operation');
|
||||
const result = await client.from('table').select('*');
|
||||
|
||||
if (result.error) {
|
||||
logger.error({ ...ctx, error: result.error }, 'Database query failed');
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'Database operation failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Best Practices
|
||||
|
||||
1. Always test migrations locally first
|
||||
2. Use transactions for complex migrations
|
||||
3. Add proper indexes for new columns
|
||||
4. Update RLS policies when adding new tables
|
||||
5. Generate TypeScript types after schema changes
|
||||
6. Take into account constraints
|
||||
7. Do not add breaking changes that would distrupt the DB to new migrations
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
1. **RLS bypass**: Admin client bypasses all RLS - validate manually
|
||||
2. **Missing indexes**: Always add indexes for foreign keys and commonly queried columns
|
||||
3. **Security definer functions**: Only use with explicit permission checks
|
||||
4. **Storage paths**: Must include account_id for proper access control
|
||||
5. **Type safety**: Always regenerate types after schema changes
|
||||
312
packages/supabase/CLAUDE.md
Normal file
312
packages/supabase/CLAUDE.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# Database & Authentication Instructions
|
||||
|
||||
This file contains instructions for working with Supabase, database security, and authentication.
|
||||
|
||||
## Database Security Guidelines ⚠️
|
||||
|
||||
**Critical Security Guidelines - Read Carefully!**
|
||||
|
||||
### Database Security Fundamentals
|
||||
|
||||
- **Always enable RLS** on new tables unless explicitly instructed otherwise
|
||||
- **NEVER use SECURITY DEFINER functions** without explicit access controls - they bypass RLS entirely
|
||||
- **Always use security_invoker=true for views** to maintain proper access control
|
||||
- **Storage buckets MUST validate access** using account_id in the path structure. See `apps/web/supabase/schemas/16-storage.sql` for proper implementation.
|
||||
- **Use locks if required**: Database locks prevent race conditions and timing attacks in concurrent operations. Make sure to take these into account for all database operations.
|
||||
|
||||
### Security Definer Function - Dangerous Pattern ❌
|
||||
|
||||
```sql
|
||||
-- NEVER DO THIS - Allows any authenticated user to call function
|
||||
CREATE OR REPLACE FUNCTION public.dangerous_function()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER AS $
|
||||
BEGIN
|
||||
-- This bypasses all RLS policies!
|
||||
DELETE FROM sensitive_table; -- Anyone can call this!
|
||||
END;
|
||||
$;
|
||||
GRANT EXECUTE ON FUNCTION public.dangerous_function() TO authenticated;
|
||||
```
|
||||
|
||||
### Security Definer Function - Safe Pattern ✅
|
||||
|
||||
```sql
|
||||
-- ONLY use SECURITY DEFINER with explicit access validation
|
||||
CREATE OR REPLACE FUNCTION public.safe_admin_function(target_account_id uuid)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = '' AS $
|
||||
BEGIN
|
||||
-- MUST validate caller has permission FIRST
|
||||
IF NOT public.is_account_owner(target_account_id) THEN
|
||||
RAISE EXCEPTION 'Access denied: insufficient permissions';
|
||||
END IF;
|
||||
|
||||
-- Now safe to proceed with elevated privileges
|
||||
-- Your admin operation here
|
||||
END;
|
||||
$;
|
||||
```
|
||||
|
||||
Only grant critical functions to `service_role`:
|
||||
|
||||
```sql
|
||||
grant execute on public.dangerous_function to service_role;
|
||||
```
|
||||
|
||||
## Existing Helper Functions - Use These! 📚
|
||||
|
||||
**DO NOT recreate these functions - they already exist:**
|
||||
|
||||
```sql
|
||||
-- Account Access Control
|
||||
public.has_role_on_account(account_id, role?) -- Check team membership
|
||||
public.has_permission(user_id, account_id, permission) -- Check permissions
|
||||
public.is_account_owner(account_id) -- Verify ownership
|
||||
public.has_active_subscription(account_id) -- Subscription status
|
||||
public.is_team_member(account_id, user_id) -- Direct membership check
|
||||
public.can_action_account_member(target_account_id, target_user_id) -- Member action rights
|
||||
|
||||
-- Administrative Functions
|
||||
public.is_super_admin() -- Super admin check
|
||||
public.is_aal2() -- MFA verification
|
||||
public.is_mfa_compliant() -- MFA compliance
|
||||
|
||||
-- Configuration
|
||||
public.is_set(field_name) -- Feature flag checks
|
||||
```
|
||||
|
||||
Always check `apps/web/supabase/schemas/` before creating new functions!
|
||||
|
||||
## RLS Policy Best Practices ✅
|
||||
|
||||
```sql
|
||||
-- Proper RLS using existing helper functions
|
||||
CREATE POLICY "notes_read" ON public.notes FOR SELECT
|
||||
TO authenticated USING (
|
||||
account_id = (select auth.uid()) OR
|
||||
public.has_role_on_account(account_id)
|
||||
);
|
||||
|
||||
-- For operations requiring specific permissions
|
||||
CREATE POLICY "notes_manage" ON public.notes FOR ALL
|
||||
TO authenticated USING (
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
```
|
||||
|
||||
## Schema Management Workflow
|
||||
|
||||
1. Create schemas in `apps/web/supabase/schemas/` as `<number>-<name>.sql`
|
||||
2. After changes: `pnpm supabase:web:stop`
|
||||
3. Run: `pnpm --filter web run supabase:db:diff -f <filename>`
|
||||
4. Restart: `pnpm supabase:web:start` and `pnpm supabase:web:reset`
|
||||
5. Generate types: `pnpm supabase:web:typegen`
|
||||
|
||||
- **Never modify database.types.ts**: Instead, use the Supabase CLI using our package.json scripts to re-generate the types after resetting the DB
|
||||
|
||||
### Key Schema Files
|
||||
|
||||
- Accounts: `apps/web/supabase/schemas/03-accounts.sql`
|
||||
- Memberships: `apps/web/supabase/schemas/05-memberships.sql`
|
||||
- Permissions: `apps/web/supabase/schemas/06-roles-permissions.sql`
|
||||
|
||||
## Type Generation
|
||||
|
||||
```typescript
|
||||
import { Tables } from '@kit/supabase/database';
|
||||
|
||||
type Account = Tables<'accounts'>;
|
||||
```
|
||||
|
||||
Always prefer inferring types from generated Database types.
|
||||
|
||||
## Client Usage Patterns
|
||||
|
||||
### Server Components (Preferred)
|
||||
|
||||
```typescript
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
async function NotesPage() {
|
||||
const client = getSupabaseServerClient();
|
||||
const { data, error } = await client.from('notes').select('*');
|
||||
|
||||
if (error) return <ErrorMessage error={error} />;
|
||||
|
||||
return <NotesList notes={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Client Components
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
function InteractiveNotes() {
|
||||
const supabase = useSupabase();
|
||||
// Use with React Query for optimal data fetching
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Client (Use with Extreme Caution) ⚠️
|
||||
|
||||
```typescript
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
async function adminFunction() {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// CRITICAL: Manual authorization required - bypasses RLS!
|
||||
const currentUser = await getCurrentUser();
|
||||
|
||||
if (!(await isSuperAdmin(currentUser))) {
|
||||
throw new Error('Unauthorized: Admin access required');
|
||||
}
|
||||
|
||||
// Now safe to proceed with admin privileges
|
||||
const { data } = await adminClient.from('table').select('*');
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication Patterns
|
||||
|
||||
### Multi-Factor Authentication
|
||||
|
||||
```typescript
|
||||
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
|
||||
|
||||
const requiresMultiFactorAuthentication =
|
||||
await checkRequiresMultiFactorAuthentication(supabase);
|
||||
|
||||
if (requiresMultiFactorAuthentication) {
|
||||
// Redirect to MFA page
|
||||
}
|
||||
```
|
||||
|
||||
### User Requirements
|
||||
|
||||
```typescript
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const user = await requireUser(client, { verifyMfa: false });
|
||||
```
|
||||
|
||||
## Storage Security
|
||||
|
||||
Storage buckets must validate access using account_id in the path structure:
|
||||
|
||||
```sql
|
||||
-- RLS policies for storage bucket account_image
|
||||
create policy account_image on storage.objects for all using (
|
||||
bucket_id = 'account_image'
|
||||
and (
|
||||
kit.get_storage_filename_as_uuid(name) = auth.uid()
|
||||
or public.has_role_on_account(kit.get_storage_filename_as_uuid(name))
|
||||
)
|
||||
)
|
||||
with check (
|
||||
bucket_id = 'account_image'
|
||||
and (
|
||||
kit.get_storage_filename_as_uuid(name) = auth.uid()
|
||||
or public.has_permission(
|
||||
auth.uid(),
|
||||
kit.get_storage_filename_as_uuid(name),
|
||||
'settings.manage'
|
||||
)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## Common Database Operations
|
||||
|
||||
### Creating Tables with RLS
|
||||
|
||||
```sql
|
||||
-- Create table
|
||||
create table if not exists public.notes (
|
||||
id uuid unique not null default extensions.uuid_generate_v4(),
|
||||
account_id uuid references public.accounts(id) on delete cascade not null,
|
||||
title varchar(255) not null,
|
||||
content text,
|
||||
created_at timestamp with time zone default now(),
|
||||
updated_at timestamp with time zone default now(),
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
-- Enable RLS
|
||||
alter table "public"."notes" enable row level security;
|
||||
|
||||
-- Grant permissions
|
||||
grant select, insert, update, delete on table public.notes to authenticated;
|
||||
|
||||
-- Create RLS policies
|
||||
create policy "notes_read" on public.notes for select
|
||||
to authenticated using (
|
||||
account_id = (select auth.uid()) or
|
||||
public.has_role_on_account(account_id)
|
||||
);
|
||||
|
||||
create policy "notes_write" on public.notes for insert
|
||||
to authenticated with check (
|
||||
account_id = (select auth.uid()) or
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
|
||||
);
|
||||
```
|
||||
|
||||
### Indexes for Performance
|
||||
|
||||
```sql
|
||||
-- Create indexes for common queries
|
||||
create index if not exists ix_notes_account_id on public.notes (account_id);
|
||||
create index if not exists ix_notes_created_at on public.notes (created_at);
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
async function databaseOperation() {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: 'database-operation', accountId: 'account-123' };
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Starting database operation');
|
||||
const result = await client.from('table').select('*');
|
||||
|
||||
if (result.error) {
|
||||
logger.error({ ...ctx, error: result.error }, 'Database query failed');
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'Database operation failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Best Practices
|
||||
|
||||
1. Always test migrations locally first
|
||||
2. Use transactions for complex migrations
|
||||
3. Add proper indexes for new columns
|
||||
4. Update RLS policies when adding new tables
|
||||
5. Generate TypeScript types after schema changes
|
||||
6. Take into account constraints
|
||||
7. Do not add breaking changes that would distrupt the DB to new migrations
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
1. **RLS bypass**: Admin client bypasses all RLS - validate manually
|
||||
2. **Missing indexes**: Always add indexes for foreign keys and commonly queried columns
|
||||
3. **Security definer functions**: Only use with explicit permission checks
|
||||
4. **Storage paths**: Must include account_id for proper access control
|
||||
5. **Type safety**: Always regenerate types after schema changes
|
||||
@@ -26,10 +26,10 @@
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@supabase/ssr": "^0.7.0",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"@types/react": "19.1.12",
|
||||
"next": "15.5.2",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"@types/react": "19.1.13",
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.1",
|
||||
"server-only": "^0.0.1",
|
||||
"zod": "^3.25.74"
|
||||
|
||||
304
packages/ui/AGENTS.md
Normal file
304
packages/ui/AGENTS.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# UI Components & Styling Instructions
|
||||
|
||||
This file contains instructions for working with UI components, styling, and forms.
|
||||
|
||||
## Core UI Library
|
||||
|
||||
Import from `packages/ui/src/`:
|
||||
|
||||
```tsx
|
||||
// Shadcn components
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card } from '@kit/ui/card';
|
||||
|
||||
// Makerkit components
|
||||
import { If } from '@kit/ui/if';
|
||||
import { ProfileAvatar } from '@kit/ui/profile-avatar';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
```
|
||||
|
||||
NB: imports must follow the convention "@kit/ui/<name>", no matter the folder they're placed in
|
||||
|
||||
## Styling Guidelines
|
||||
|
||||
- Use **Tailwind CSS v4** with semantic classes
|
||||
- Prefer Shadcn-ui classes like `bg-background`, `text-muted-foreground`
|
||||
- Use `cn()` utility from `@kit/ui/cn` for class merging
|
||||
|
||||
```tsx
|
||||
import { cn } from '@kit/ui/cn';
|
||||
|
||||
function MyComponent({ className }) {
|
||||
return (
|
||||
<div className={cn('bg-background text-foreground', className)}>
|
||||
Content
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Rendering
|
||||
|
||||
Use the `If` component from `packages/ui/src/makerkit/if.tsx`:
|
||||
|
||||
```tsx
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
<If condition={isLoading} fallback={<Content />}>
|
||||
<Spinner />
|
||||
</If>
|
||||
|
||||
// With type inference
|
||||
<If condition={error}>
|
||||
{(err) => <ErrorMessage error={err} />}
|
||||
</If>
|
||||
```
|
||||
|
||||
### Testing Attributes
|
||||
|
||||
```tsx
|
||||
<button data-test="submit-button">Submit</button>
|
||||
<div data-test="user-profile" data-user-id={user.id}>Profile</div>
|
||||
```
|
||||
|
||||
## Forms with React Hook Form & Zod
|
||||
|
||||
```typescript
|
||||
// 1. Schema in separate file
|
||||
export const CreateNoteSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
});
|
||||
|
||||
// 2. Client component with form
|
||||
'use client';
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateNoteSchema),
|
||||
});
|
||||
|
||||
const onSubmit = (data) => {
|
||||
startTransition(async () => {
|
||||
await toast.promise(createNoteAction(data), {
|
||||
loading: 'Creating...',
|
||||
success: 'Created!',
|
||||
error: 'Failed!',
|
||||
}).unwrap();
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Form Examples
|
||||
|
||||
- Contact form: `apps/web/app/(marketing)/contact/_components/contact-form.tsx`
|
||||
- Verify OTP form: `packages/otp/src/components/verify-otp-form.tsx`
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Place Zod resolver outside so it can be reused with Server Actions
|
||||
- Never add generics to `useForm`, use Zod resolver to infer types instead
|
||||
- Never use `watch()` instead use hook `useWatch`
|
||||
- Add `FormDescription` (optionally) and always add `FormMessage` to display errors
|
||||
|
||||
## Internationalization
|
||||
|
||||
Always use `Trans` component from `packages/ui/src/makerkit/trans.tsx`:
|
||||
|
||||
```tsx
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
<Trans
|
||||
i18nKey="user:welcomeMessage"
|
||||
values={{ name: user.name }}
|
||||
/>
|
||||
|
||||
// With HTML elements
|
||||
<Trans
|
||||
i18nKey="terms:agreement"
|
||||
components={{
|
||||
TermsLink: <a href="/terms" className="underline" />,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Toast Notifications
|
||||
|
||||
Use the `toast` utility from `@kit/ui/sonner`:
|
||||
|
||||
```tsx
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
// Simple toast
|
||||
toast.success('Success message');
|
||||
toast.error('Error message');
|
||||
|
||||
// Promise-based toast
|
||||
await toast.promise(asyncFunction(), {
|
||||
loading: 'Processing...',
|
||||
success: 'Done!',
|
||||
error: 'Failed!',
|
||||
});
|
||||
```
|
||||
|
||||
## Common Component Patterns
|
||||
|
||||
### Loading States
|
||||
|
||||
```tsx
|
||||
import { Spinner } from '@kit/ui/spinner';
|
||||
|
||||
<If condition={isLoading} fallback={<Content />}>
|
||||
<Spinner className="h-4 w-4" />
|
||||
</If>
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```tsx
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
|
||||
<If condition={Boolean(error)}>
|
||||
<Alert variant="destructive">
|
||||
<ExclamationTriangleIcon className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
```
|
||||
|
||||
### Button Patterns
|
||||
|
||||
```tsx
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
// Loading button
|
||||
<Button disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Spinner className="mr-2 h-4 w-4" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Submit'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
// Variants
|
||||
<Button variant="default">Default</Button>
|
||||
<Button variant="destructive">Delete</Button>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
```
|
||||
|
||||
### Card Layouts
|
||||
|
||||
```tsx
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Card Title</CardTitle>
|
||||
<CardDescription>Card description</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
Card content goes here
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
## Form Components
|
||||
|
||||
### Input Fields
|
||||
|
||||
```tsx
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { FormField, FormItem, FormLabel, FormControl, FormMessage } from '@kit/ui/form';
|
||||
|
||||
<FormField
|
||||
name="title"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input placeholder="Enter title" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
The title of your task
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
### Select Components
|
||||
|
||||
```tsx
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kit/ui/select';
|
||||
|
||||
<FormField
|
||||
name="category"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<FormDescription>
|
||||
The category of your task
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Accessibility Guidelines
|
||||
|
||||
- Always include proper ARIA labels
|
||||
- Use semantic HTML elements
|
||||
- Ensure proper keyboard navigation
|
||||
|
||||
```tsx
|
||||
<button
|
||||
aria-label="Close modal"
|
||||
aria-describedby="modal-description"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
```
|
||||
|
||||
## Dark Mode Support
|
||||
|
||||
The UI components automatically support dark mode through CSS variables. Use semantic color classes:
|
||||
|
||||
```tsx
|
||||
// Good - semantic colors
|
||||
<div className="bg-background text-foreground border-border">
|
||||
<p className="text-muted-foreground">Secondary text</p>
|
||||
</div>
|
||||
|
||||
// Avoid - hardcoded colors
|
||||
<div className="bg-white text-black border-gray-200">
|
||||
<p className="text-gray-500">Secondary text</p>
|
||||
</div>
|
||||
```
|
||||
289
packages/ui/CLAUDE.md
Normal file
289
packages/ui/CLAUDE.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# UI Components & Styling Instructions
|
||||
|
||||
This file contains instructions for working with UI components, styling, and forms.
|
||||
|
||||
## Core UI Library
|
||||
|
||||
Import from `packages/ui/src/`:
|
||||
|
||||
```tsx
|
||||
// Shadcn components
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card } from '@kit/ui/card';
|
||||
// Makerkit components
|
||||
import { If } from '@kit/ui/if';
|
||||
import { ProfileAvatar } from '@kit/ui/profile-avatar';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
```
|
||||
|
||||
## Styling Guidelines
|
||||
|
||||
- Use **Tailwind CSS v4** with semantic classes
|
||||
- Prefer Shadcn-ui classes like `bg-background`, `text-muted-foreground`
|
||||
- Use `cn()` utility from `@kit/ui/cn` for class merging
|
||||
|
||||
```tsx
|
||||
import { cn } from '@kit/ui/cn';
|
||||
|
||||
function MyComponent({ className }) {
|
||||
return (
|
||||
<div className={cn('bg-background text-foreground', className)}>
|
||||
Content
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Rendering
|
||||
|
||||
Use the `If` component from `packages/ui/src/makerkit/if.tsx`:
|
||||
|
||||
```tsx
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
<If condition={isLoading} fallback={<Content />}>
|
||||
<Spinner />
|
||||
</If>
|
||||
|
||||
// With type inference
|
||||
<If condition={error}>
|
||||
{(err) => <ErrorMessage error={err} />}
|
||||
</If>
|
||||
```
|
||||
|
||||
### Testing Attributes
|
||||
|
||||
```tsx
|
||||
<button data-test="submit-button">Submit</button>
|
||||
<div data-test="user-profile" data-user-id={user.id}>Profile</div>
|
||||
```
|
||||
|
||||
## Forms with React Hook Form & Zod
|
||||
|
||||
```typescript
|
||||
// 1. Schema in separate file
|
||||
export const CreateNoteSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
});
|
||||
|
||||
// 2. Client component with form
|
||||
'use client';
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateNoteSchema),
|
||||
});
|
||||
|
||||
const onSubmit = (data) => {
|
||||
startTransition(async () => {
|
||||
await toast.promise(createNoteAction(data), {
|
||||
loading: 'Creating...',
|
||||
success: 'Created!',
|
||||
error: 'Failed!',
|
||||
}).unwrap();
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Place Zod resolver outside so it can be reused with Server Actions
|
||||
- Never add generics to `useForm`, use Zod resolver to infer types instead
|
||||
- Never use `watch()` instead use hook `useWatch`
|
||||
- Add `FormDescription` (optionally) and always add `FormMessage` to display errors
|
||||
|
||||
### Form Examples
|
||||
|
||||
- Contact form: `apps/web/app/(marketing)/contact/_components/contact-form.tsx`
|
||||
- Verify OTP form: `packages/otp/src/components/verify-otp-form.tsx`
|
||||
|
||||
## Internationalization
|
||||
|
||||
Always use `Trans` component from `packages/ui/src/makerkit/trans.tsx`:
|
||||
|
||||
```tsx
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
<Trans
|
||||
i18nKey="user:welcomeMessage"
|
||||
values={{ name: user.name }}
|
||||
/>
|
||||
|
||||
// With HTML elements
|
||||
<Trans
|
||||
i18nKey="terms:agreement"
|
||||
components={{
|
||||
TermsLink: <a href="/terms" className="underline" />,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Toast Notifications
|
||||
|
||||
Use the `toast` utility from `@kit/ui/sonner`:
|
||||
|
||||
```tsx
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
// Simple toast
|
||||
toast.success('Success message');
|
||||
toast.error('Error message');
|
||||
|
||||
// Promise-based toast
|
||||
await toast.promise(asyncFunction(), {
|
||||
loading: 'Processing...',
|
||||
success: 'Done!',
|
||||
error: 'Failed!',
|
||||
});
|
||||
```
|
||||
|
||||
## Common Component Patterns
|
||||
|
||||
### Loading States
|
||||
|
||||
```tsx
|
||||
import { Spinner } from '@kit/ui/spinner';
|
||||
|
||||
<If condition={isLoading} fallback={<Content />}>
|
||||
<Spinner className="h-4 w-4" />
|
||||
</If>
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```tsx
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
|
||||
<If condition={Boolean(error)}>
|
||||
<Alert variant="destructive">
|
||||
<ExclamationTriangleIcon className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
```
|
||||
|
||||
### Button Patterns
|
||||
|
||||
```tsx
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
// Loading button
|
||||
<Button disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Spinner className="mr-2 h-4 w-4" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Submit'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
// Variants
|
||||
<Button variant="default">Default</Button>
|
||||
<Button variant="destructive">Delete</Button>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
```
|
||||
|
||||
### Card Layouts
|
||||
|
||||
```tsx
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Card Title</CardTitle>
|
||||
<CardDescription>Card description</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
Card content goes here
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
## Form Components
|
||||
|
||||
### Input Fields
|
||||
|
||||
```tsx
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { FormField, FormItem, FormLabel, FormControl, FormMessage } from '@kit/ui/form';
|
||||
|
||||
<FormField
|
||||
name="title"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter title" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
### Select Components
|
||||
|
||||
```tsx
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kit/ui/select';
|
||||
|
||||
<FormField
|
||||
name="category"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Accessibility Guidelines
|
||||
|
||||
- Always include proper ARIA labels
|
||||
- Use semantic HTML elements
|
||||
- Ensure proper keyboard navigation
|
||||
|
||||
```tsx
|
||||
<button
|
||||
aria-label="Close modal"
|
||||
aria-describedby="modal-description"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
```
|
||||
|
||||
## Dark Mode Support
|
||||
|
||||
The UI components automatically support dark mode through CSS variables. Use semantic color classes:
|
||||
|
||||
```tsx
|
||||
// Good - semantic colors
|
||||
<div className="bg-background text-foreground border-border">
|
||||
<p className="text-muted-foreground">Secondary text</p>
|
||||
</div>
|
||||
|
||||
// Avoid - hardcoded colors
|
||||
<div className="bg-white text-black border-gray-200">
|
||||
<p className="text-gray-500">Secondary text</p>
|
||||
</div>
|
||||
```
|
||||
@@ -9,12 +9,12 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"input-otp": "1.4.2",
|
||||
"lucide-react": "^0.542.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"radix-ui": "1.4.3",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-top-loading-bar": "3.0.2",
|
||||
@@ -25,18 +25,18 @@
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@supabase/supabase-js": "2.57.2",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react": "19.1.13",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"eslint": "^9.35.0",
|
||||
"next": "15.5.2",
|
||||
"next": "15.5.3",
|
||||
"next-themes": "0.4.6",
|
||||
"prettier": "^3.6.2",
|
||||
"react-day-picker": "^9.9.0",
|
||||
"react-day-picker": "^9.10.0",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-i18next": "^15.7.3",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
Reference in New Issue
Block a user