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:
Giancarlo Buomprisco
2025-09-17 11:36:02 +08:00
committed by GitHub
parent 9fae142f2d
commit 533dfba5b9
83 changed files with 9223 additions and 2974 deletions

View 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,
});
```

View 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,
});
```

View File

@@ -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": {
"*": {

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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"
},

View File

@@ -20,7 +20,7 @@
"@kit/shared": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/wordpress": "workspace:*",
"@types/node": "^24.3.1"
"@types/node": "^24.5.0"
},
"typesVersions": {
"*": {

View File

@@ -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"
},

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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
View 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
View 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');
}
```

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 (
<>

View File

@@ -168,6 +168,7 @@ function getColumns(): ColumnDef<Account>[] {
cell: ({ row }) => {
return (
<Link
prefetch={false}
className={'hover:underline'}
href={`/admin/accounts/${row.original.id}`}
>

View File

@@ -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));
}
});
})}

View File

@@ -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));
}
});
})}

View File

@@ -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,

View File

@@ -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",

View File

@@ -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"

View File

@@ -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",

View File

@@ -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"

View 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,
});
```

View 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,
});
```

View File

@@ -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": {

View File

@@ -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
View File

@@ -0,0 +1 @@
build

View 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.

View File

@@ -0,0 +1,3 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View 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"
}

View 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);
});

View File

View 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,
},
],
};
},
);
}

View 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()}`,
},
],
};
},
);
}

View 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')}`,
},
],
};
},
);
}

View 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
View 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`,
);

View 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"]
}

View File

@@ -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"
},

View File

@@ -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": {

View File

@@ -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
View 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
View 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.

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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
View 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
View 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

View File

@@ -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
View 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
View 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>
```

View File

@@ -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",