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

@@ -3,6 +3,7 @@ description: Writing Forms with Shadcn UI, Server Actions, Zod
globs: apps/**/*.tsx,packages/**/*.tsx
alwaysApply: false
---
# Forms
- Use React Hook Form for form validation and submission.
@@ -10,6 +11,7 @@ alwaysApply: false
- Use the `zodResolver` function to resolve the Zod schema to the form.
- Use Server Actions [server-actions.mdc](mdc:.cursor/rules/server-actions.mdc) for server-side code handling
- Use Sonner for writing toasters for UI feedback
- Never add generics to `useForm`, use Zod resolver to infer types instead
Follow the example below to create all forms:
@@ -35,7 +37,9 @@ Server Actions [server-actions.mdc](mdc:.cursor/rules/server-actions.mdc) can he
'use server';
import { z } from 'zod';
import { enhanceAction } from '@kit/next/actions';
import { CreateNoteSchema } from '../schema/create-note.schema';
export const createNoteAction = enhanceAction(

1
.gemini/settings.json Normal file
View File

@@ -0,0 +1 @@
{ "contextFileName": "AGENTS.md" }

View File

@@ -15,7 +15,7 @@ class UserService {
}
export function createUserService() {
return new UserService();
return new UserService();
}
```

610
AGENTS.md
View File

@@ -1,25 +1,23 @@
# AGENTS.md
This file provides guidance to Claude Code when working with code in this repository.
This AGENTS.md file provides comprehensive guidance for OpenAI Codex and other AI agents working with this codebase.
## Core Technologies
### Core Technologies
- **Next.js 15** with App Router and Turbopack
- **Next.js 15** with App Router
- **Supabase** for database, auth, and storage
- **React 19** with React Compiler
- **TypeScript** with strict configuration
- **Tailwind CSS 4** for styling
- **Turborepo** for monorepo management
- **React 19**
- **TypeScript**
- **Tailwind CSS 4** and Shadcn UI
- **Turborepo**
### Monorepo Structure
## Monorepo Structure
- @apps/web - Main Next.js SaaS application
- @apps/dev-tool - Development utilities (port 3010)
- @apps/e2e - Playwright end-to-end tests
- @packages/ - Shared packages and utilities
- @tooling/ - Build tools and development scripts
- `apps/web` - Main Next.js SaaS application
- `apps/e2e` - Playwright end-to-end tests
- `packages/features/*` - Feature packages
- `packages/` - Shared packages and utilities
- `tooling/` - Build tools and development scripts
### Multi-Tenant Architecture
## Multi-Tenant Architecture
**Personal Accounts**: Individual user accounts (auth.users.id = accounts.id)
**Team Accounts**: Shared workspaces with members, roles, and permissions
@@ -33,8 +31,6 @@ Data associates with accounts via foreign keys for proper access control.
```bash
pnpm dev # Start all apps
pnpm --filter web dev # Main app (port 3000)
pnpm --filter dev-tool dev # Dev tools (port 3010)
pnpm build # Build all apps
```
### Database Operations
@@ -49,570 +45,28 @@ pnpm --filter web supabase:db:diff # Create migration
### Code Quality
```bash
pnpm lint && pnpm format # Lint and format
pnpm typecheck # Type checking
pnpm test # Run tests
pnpm format:fix
pnpm lint:fix
pnpm typecheck
```
## Application Structure
- Run the typecheck command regularly to ensure your code is type-safe.
- Run the linter and the formatter when your task is complete.
### Route Organization
## Typescript
```
app/
├── (marketing)/ # Public pages (landing, blog, docs)
├── (auth)/ # Authentication pages
├── home/
│ ├── (user)/ # Personal account context
│ └── [account]/ # Team account context ([account] = team slug)
├── admin/ # Super admin section
└── api/ # API routes
```
- Write clean, clear, well-designed, explicit TypeScript
- Avoid obvious comments
- Avoid unnecessary complexity or overly abstract code
- Always use implicit type inference, unless impossible
- You must avoid using `any`
- Handle errors gracefully using try/catch and appropriate error types
Key Examples:
## React
- Marketing layout: @apps/web/app/(marketing)/layout.tsx
- Personal dashboard: @apps/web/app/home/(user)/page.tsx
- Team workspace: @apps/web/app/home/[account]/page.tsx
- Admin section: @apps/web/app/admin/page.tsx
### Component Organization
- **Route-specific**: Use \_components/ directories
- **Route utilities**: Use \_lib/ for client, \_lib/server/ for server-side
- **Global components**: Root-level directories
Example:
- Team components: @apps/web/app/home/[account]/\_components/
- Team server utils: @apps/web/app/home/[account]/\_lib/server/
- Marketing components: @apps/web/app/(marketing)/\_components/
## Database Guidelines
### Security & RLS Implementation
**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;
$;
```
#### 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`
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.
## Development Patterns
### Data Fetching Strategy
**Quick Decision Framework:**
- **Server Components**: Default choice for initial data loading
- **Client Components**: For interactive features requiring hooks or real-time updates
- **Admin Client**: Only for bypassing RLS (rare cases - requires manual auth/authorization)
#### 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} />;
}
```
**Key Insight**: Server Components automatically inherit RLS protection - no additional authorization checks needed!
#### Client Components (Interactive) 🖱️
```typescript
'use client';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { useQuery } from '@tanstack/react-query';
function InteractiveNotes() {
const supabase = useSupabase();
const { data, isLoading } = useQuery({
queryKey: ['notes'],
queryFn: () => supabase.from('notes').select('*')
});
if (isLoading) return <Spinner />;
return <NotesList notes={data} />;
}
```
#### Performance Optimization - Parallel Data Fetching 🚀
**Sequential (Slow) Pattern ❌**
```typescript
async function SlowDashboard() {
const userData = await loadUserData();
const notifications = await loadNotifications();
const metrics = await loadMetrics();
// Total time: sum of all requests
}
```
**Parallel (Optimized) Pattern ✅**
```typescript
async function FastDashboard() {
// Execute all requests simultaneously
const [userData, notifications, metrics] = await Promise.all([
loadUserData(),
loadNotifications(),
loadMetrics()
]);
// Total time: longest single request
return <Dashboard user={userData} notifications={notifications} metrics={metrics} />;
}
```
**Performance Impact**: Parallel fetching can reduce page load time by 60-80% for multi-data pages!
### Authorization Patterns - Critical Understanding 🔐
#### RLS-Protected Data Fetching (Standard) ✅
```typescript
async function getUserNotes(userId: string) {
const client = getSupabaseServerClient();
// RLS automatically ensures user can only access their own notes
// NO additional authorization checks needed!
const { data } = await client.from('notes').select('*').eq('user_id', userId); // RLS validates this automatically
return data;
}
```
#### Admin Client Usage (Dangerous - Rare Cases Only) ⚠️
```typescript
async function adminGetUserNotes(userId: string) {
const adminClient = getSupabaseServerAdminClient();
// CRITICAL: Manual authorization required - bypasses RLS!
const currentUser = await getCurrentUser();
if (!(await isSuperAdmin(currentUser))) {
throw new Error('Unauthorized: Admin access required');
}
// Additional validation: ensure current admin isn't targeting themselves
if (currentUser.id === userId) {
throw new Error('Cannot perform admin action on own account');
}
// Now safe to proceed with admin privileges
const { data } = await adminClient
.from('notes')
.select('*')
.eq('user_id', userId);
return data;
}
```
**Rule of thumb**: If using standard Supabase client, trust RLS. If using admin client, validate everything manually.
### Server Actions Implementation
Always use `enhanceAction` from @packages/next/src/actions/index.ts:
```typescript
'use server';
import { enhanceAction } from '@kit/next/actions';
export const createNoteAction = enhanceAction(
async function (data, user) {
// data is validated, user is authenticated
return { success: true };
},
{
auth: true,
schema: CreateNoteSchema,
},
);
```
Example server actions:
- 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
### 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!',
});
});
};
```
Form examples:
- Contact form: @apps/web/app/(marketing)/contact/\_components/contact-form.tsx
- Verify OTP form: @packages/otp/src/components/verify-otp-form.tsx
### Route Handlers (API Routes)
Use `enhanceRouteHandler` from @packages/next/src/routes/index.ts:
```typescript
import { enhanceRouteHandler } from '@kit/next/routes';
export const POST = enhanceRouteHandler(
async function ({ body, user, request }) {
// body is validated, user available if auth: true
return NextResponse.json({ success: true });
},
{
auth: true,
schema: ZodSchema,
},
);
```
## React & TypeScript Best Practices
### TS
- Write clean, clear, well-designed, explicit Typescript
- Use implicit type inference, unless impossible
- `any` and `unknown` are a code smell and must justified if used
- Handle errors gracefully using try/catch and appropriate error types.
### Components
- Use functional components with TypeScript
- Use functional components
- Always use 'use client' directive for client components
- Destructure props with proper TypeScript interfaces
- Name files to match component name (e.g., user-profile.tsx)
### 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>
```
### 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" />,
}}
/>
```
Adding new languages:
1. Add language code to @apps/web/lib/i18n/i18n.settings.ts
2. Create translation files in @apps/web/public/locales/[new-language]/
3. Copy structure from English files
Translation files: @apps/web/public/locales/<locale>/<namespace>.json
## Security Guidelines 🛡️
### Authentication & Authorization
- Authentication enforced by middleware
- Authorization handled by RLS at database level
- Avoid defensive code - use RLS instead
- For admin client usage, enforce both authentication and authorization
### Data Passing
- **Never pass sensitive data** to Client Components
- **Never expose server environment variables** to client (unless prefixed with NEXT_PUBLIC)
- Always validate user input
### 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
}}
/>;
```
### Super Admin Protection
For admin routes, use `AdminGuard` from @packages/features/admin/src/components/admin-guard.tsx:
```tsx
import { AdminGuard } from '@kit/admin/components/admin-guard';
export default AdminGuard(AdminPageComponent);
```
## UI Components 🎨
### 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
- 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
## Workspace Contexts 🏢
### Personal Account Context (@apps/web/app/home/(user))
```tsx
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
function PersonalComponent() {
const { user, account } = useUserWorkspace();
// Personal account data
}
```
Context provider: @packages/features/accounts/src/components/user-workspace-context-provider.tsx
### Team Account Context (@apps/web/app/home/[account])
```tsx
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
function TeamComponent() {
const { account, user, accounts } = useTeamAccountWorkspace();
// Team account data with permissions
}
```
Context provider: @packages/features/team-accounts/src/components/team-account-workspace-context-provider.tsx
## Error Handling & Logging 📊
### Structured Logging
Use logger from @packages/shared/src/logger/logger.ts:
```typescript
import { getLogger } from '@kit/shared/logger';
async function myServerAction() {
const logger = await getLogger();
const ctx = { name: 'myOperation', userId: user.id };
try {
logger.info(ctx, 'Operation started');
// ...
} catch (error) {
logger.error({ ...ctx, error }, 'Operation failed');
// handle error
}
}
```
## API Services
### Account Services
- Personal accounts API: @packages/features/accounts/src/server/api.ts
- Team accounts API: @packages/features/team-accounts/src/server/api.ts
- Admin service: @packages/features/admin/src/lib/server/services/admin.service.ts
### Billing Services
- Personal billing: @apps/web/app/home/(user)/billing/\_lib/server/user-billing.service.ts
- Team billing: @apps/web/app/home/[account]/billing/\_lib/server/team-billing.service.ts
- Per-seat billing: @packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts
## Key Configuration Files
- **Feature flags**: @apps/web/config/feature-flags.config.ts
- **i18n settings**: @apps/web/lib/i18n/i18n.settings.ts
- **Supabase config**: @apps/web/supabase/config.toml
- **Middleware**: @apps/web/middleware.ts
## Quick Reference Checklist ✅
### Development Workflow
- [ ] Enable RLS on new tables
- [ ] Generate TypeScript types after schema changes and infer types from these
- [ ] Implement proper error handling with logging
- [ ] Use Zod schemas for parsing all user input (including cookies, query params, etc.)
- [ ] Add testing attributes to interactive elements
- [ ] Validate permissions before sensitive operations
- Add `data-test` for E2E tests where appropriate
- `useEffect` is a code smell and must be justified - avoid if possible
- Do not write many separate `useState`, prefer single state object (unless required)
- Prefer server-side data fetching using RSC

608
CLAUDE.md
View File

@@ -1,25 +1,23 @@
# CLAUDE.md
This file provides guidance to Claude Code when working with code in this repository.
### Core Technologies
## Core Technologies
- **Next.js 15** with App Router and Turbopack
- **Next.js 15** with App Router
- **Supabase** for database, auth, and storage
- **React 19** with React Compiler
- **TypeScript** with strict configuration
- **Tailwind CSS 4** for styling
- **Turborepo** for monorepo management
- **React 19**
- **TypeScript**
- **Tailwind CSS 4** and Shadcn UI
- **Turborepo**
### Monorepo Structure
## Monorepo Structure
- @apps/web - Main Next.js SaaS application
- @apps/dev-tool - Development utilities (port 3010)
- @apps/e2e - Playwright end-to-end tests
- @packages/ - Shared packages and utilities
- @tooling/ - Build tools and development scripts
- `apps/web` - Main Next.js SaaS application
- `apps/e2e` - Playwright end-to-end tests
- `packages/features/*` - Feature packages
- `packages/` - Shared packages and utilities
- `tooling/` - Build tools and development scripts
### Multi-Tenant Architecture
## Multi-Tenant Architecture
**Personal Accounts**: Individual user accounts (auth.users.id = accounts.id)
**Team Accounts**: Shared workspaces with members, roles, and permissions
@@ -33,8 +31,6 @@ Data associates with accounts via foreign keys for proper access control.
```bash
pnpm dev # Start all apps
pnpm --filter web dev # Main app (port 3000)
pnpm --filter dev-tool dev # Dev tools (port 3010)
pnpm build # Build all apps
```
### Database Operations
@@ -49,570 +45,28 @@ pnpm --filter web supabase:db:diff # Create migration
### Code Quality
```bash
pnpm lint && pnpm format # Lint and format
pnpm typecheck # Type checking
pnpm test # Run tests
pnpm format:fix
pnpm lint:fix
pnpm typecheck
```
## Application Structure
- Run the typecheck command regularly to ensure your code is type-safe.
- Run the linter and the formatter when your task is complete.
### Route Organization
## Typescript
```
app/
├── (marketing)/ # Public pages (landing, blog, docs)
├── (auth)/ # Authentication pages
├── home/
│ ├── (user)/ # Personal account context
│ └── [account]/ # Team account context ([account] = team slug)
├── admin/ # Super admin section
└── api/ # API routes
```
- Write clean, clear, well-designed, explicit TypeScript
- Avoid obvious comments
- Avoid unnecessary complexity or overly abstract code
- Always use implicit type inference, unless impossible
- You must avoid using `any`
- Handle errors gracefully using try/catch and appropriate error types
Key Examples:
## React
- Marketing layout: @apps/web/app/(marketing)/layout.tsx
- Personal dashboard: @apps/web/app/home/(user)/page.tsx
- Team workspace: @apps/web/app/home/[account]/page.tsx
- Admin section: @apps/web/app/admin/page.tsx
### Component Organization
- **Route-specific**: Use \_components/ directories
- **Route utilities**: Use \_lib/ for client, \_lib/server/ for server-side
- **Global components**: Root-level directories
Example:
- Team components: @apps/web/app/home/[account]/\_components/
- Team server utils: @apps/web/app/home/[account]/\_lib/server/
- Marketing components: @apps/web/app/(marketing)/\_components/
## Database Guidelines
### Security & RLS Implementation
**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;
$;
```
#### 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`
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.
## Development Patterns
### Data Fetching Strategy
**Quick Decision Framework:**
- **Server Components**: Default choice for initial data loading
- **Client Components**: For interactive features requiring hooks or real-time updates
- **Admin Client**: Only for bypassing RLS (rare cases - requires manual auth/authorization)
#### 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} />;
}
```
**Key Insight**: Server Components automatically inherit RLS protection - no additional authorization checks needed!
#### Client Components (Interactive) 🖱️
```typescript
'use client';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { useQuery } from '@tanstack/react-query';
function InteractiveNotes() {
const supabase = useSupabase();
const { data, isLoading } = useQuery({
queryKey: ['notes'],
queryFn: () => supabase.from('notes').select('*')
});
if (isLoading) return <Spinner />;
return <NotesList notes={data} />;
}
```
#### Performance Optimization - Parallel Data Fetching 🚀
**Sequential (Slow) Pattern ❌**
```typescript
async function SlowDashboard() {
const userData = await loadUserData();
const notifications = await loadNotifications();
const metrics = await loadMetrics();
// Total time: sum of all requests
}
```
**Parallel (Optimized) Pattern ✅**
```typescript
async function FastDashboard() {
// Execute all requests simultaneously
const [userData, notifications, metrics] = await Promise.all([
loadUserData(),
loadNotifications(),
loadMetrics()
]);
// Total time: longest single request
return <Dashboard user={userData} notifications={notifications} metrics={metrics} />;
}
```
**Performance Impact**: Parallel fetching can reduce page load time by 60-80% for multi-data pages!
### Authorization Patterns - Critical Understanding 🔐
#### RLS-Protected Data Fetching (Standard) ✅
```typescript
async function getUserNotes(userId: string) {
const client = getSupabaseServerClient();
// RLS automatically ensures user can only access their own notes
// NO additional authorization checks needed!
const { data } = await client.from('notes').select('*').eq('user_id', userId); // RLS validates this automatically
return data;
}
```
#### Admin Client Usage (Dangerous - Rare Cases Only) ⚠️
```typescript
async function adminGetUserNotes(userId: string) {
const adminClient = getSupabaseServerAdminClient();
// CRITICAL: Manual authorization required - bypasses RLS!
const currentUser = await getCurrentUser();
if (!(await isSuperAdmin(currentUser))) {
throw new Error('Unauthorized: Admin access required');
}
// Additional validation: ensure current admin isn't targeting themselves
if (currentUser.id === userId) {
throw new Error('Cannot perform admin action on own account');
}
// Now safe to proceed with admin privileges
const { data } = await adminClient
.from('notes')
.select('*')
.eq('user_id', userId);
return data;
}
```
**Rule of thumb**: If using standard Supabase client, trust RLS. If using admin client, validate everything manually.
### Server Actions Implementation
Always use `enhanceAction` from @packages/next/src/actions/index.ts:
```typescript
'use server';
import { enhanceAction } from '@kit/next/actions';
export const createNoteAction = enhanceAction(
async function (data, user) {
// data is validated, user is authenticated
return { success: true };
},
{
auth: true,
schema: CreateNoteSchema,
},
);
```
Example server actions:
- 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
### 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!',
});
});
};
```
Form examples:
- Contact form: @apps/web/app/(marketing)/contact/\_components/contact-form.tsx
- Verify OTP form: @packages/otp/src/components/verify-otp-form.tsx
### Route Handlers (API Routes)
Use `enhanceRouteHandler` from @packages/next/src/routes/index.ts:
```typescript
import { enhanceRouteHandler } from '@kit/next/routes';
export const POST = enhanceRouteHandler(
async function ({ body, user, request }) {
// body is validated, user available if auth: true
return NextResponse.json({ success: true });
},
{
auth: true,
schema: ZodSchema,
},
);
```
## React & TypeScript Best Practices
### TS
- Write clean, clear, well-designed, explicit Typescript
- Use implicit type inference, unless impossible
- `any` and `unknown` are a code smell and must justified if used
- Handle errors gracefully using try/catch and appropriate error types.
### Components
- Use functional components with TypeScript
- Use functional components
- Always use 'use client' directive for client components
- Destructure props with proper TypeScript interfaces
- Name files to match component name (e.g., user-profile.tsx)
### 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>
```
### 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" />,
}}
/>
```
Adding new languages:
1. Add language code to @apps/web/lib/i18n/i18n.settings.ts
2. Create translation files in @apps/web/public/locales/[new-language]/
3. Copy structure from English files
Translation files: @apps/web/public/locales/<locale>/<namespace>.json
## Security Guidelines 🛡️
### Authentication & Authorization
- Authentication enforced by middleware
- Authorization handled by RLS at database level
- Avoid defensive code - use RLS instead
- For admin client usage, enforce both authentication and authorization
### Data Passing
- **Never pass sensitive data** to Client Components
- **Never expose server environment variables** to client (unless prefixed with NEXT_PUBLIC)
- Always validate user input
### 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
}}
/>;
```
### Super Admin Protection
For admin routes, use `AdminGuard` from @packages/features/admin/src/components/admin-guard.tsx:
```tsx
import { AdminGuard } from '@kit/admin/components/admin-guard';
export default AdminGuard(AdminPageComponent);
```
## UI Components 🎨
### 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
- 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
## Workspace Contexts 🏢
### Personal Account Context (@apps/web/app/home/(user))
```tsx
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
function PersonalComponent() {
const { user, account } = useUserWorkspace();
// Personal account data
}
```
Context provider: @packages/features/accounts/src/components/user-workspace-context-provider.tsx
### Team Account Context (@apps/web/app/home/[account])
```tsx
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
function TeamComponent() {
const { account, user, accounts } = useTeamAccountWorkspace();
// Team account data with permissions
}
```
Context provider: @packages/features/team-accounts/src/components/team-account-workspace-context-provider.tsx
## Error Handling & Logging 📊
### Structured Logging
Use logger from @packages/shared/src/logger/logger.ts:
```typescript
import { getLogger } from '@kit/shared/logger';
async function myServerAction() {
const logger = await getLogger();
const ctx = { name: 'myOperation', userId: user.id };
try {
logger.info(ctx, 'Operation started');
// ...
} catch (error) {
logger.error({ ...ctx, error }, 'Operation failed');
// handle error
}
}
```
## API Services
### Account Services
- Personal accounts API: @packages/features/accounts/src/server/api.ts
- Team accounts API: @packages/features/team-accounts/src/server/api.ts
- Admin service: @packages/features/admin/src/lib/server/services/admin.service.ts
### Billing Services
- Personal billing: @apps/web/app/home/(user)/billing/\_lib/server/user-billing.service.ts
- Team billing: @apps/web/app/home/[account]/billing/\_lib/server/team-billing.service.ts
- Per-seat billing: @packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts
## Key Configuration Files
- **Feature flags**: @apps/web/config/feature-flags.config.ts
- **i18n settings**: @apps/web/lib/i18n/i18n.settings.ts
- **Supabase config**: @apps/web/supabase/config.toml
- **Middleware**: @apps/web/middleware.ts
## Quick Reference Checklist ✅
### Development Workflow
- [ ] Enable RLS on new tables
- [ ] Generate TypeScript types after schema changes and infer types from these
- [ ] Implement proper error handling with logging
- [ ] Use Zod schemas for parsing all user input (including cookies, query params, etc.)
- [ ] Add testing attributes to interactive elements
- [ ] Validate permissions before sensitive operations
- Add `data-test` for E2E tests where appropriate
- `useEffect` is a code smell and must be justified - avoid if possible
- Do not write many separate `useState`, prefer single state object (unless required)
- Prefer server-side data fetching using RSC

View File

@@ -8,13 +8,13 @@
"format": "prettier --check --write \"**/*.{js,cjs,mjs,ts,tsx,md,json}\""
},
"dependencies": {
"@ai-sdk/openai": "^2.0.24",
"@ai-sdk/openai": "^2.0.30",
"@faker-js/faker": "^10.0.0",
"@hookform/resolvers": "^5.2.1",
"@tanstack/react-query": "5.87.1",
"ai": "5.0.33",
"lucide-react": "^0.542.0",
"next": "15.5.2",
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "5.89.0",
"ai": "5.0.44",
"lucide-react": "^0.544.0",
"next": "15.5.3",
"nodemailer": "^7.0.6",
"react": "19.1.1",
"react-dom": "19.1.1",
@@ -28,9 +28,9 @@
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@tailwindcss/postcss": "^4.1.13",
"@types/node": "^24.3.1",
"@types/node": "^24.5.0",
"@types/nodemailer": "7.0.1",
"@types/react": "19.1.12",
"@types/react": "19.1.13",
"@types/react-dom": "19.1.9",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"pino-pretty": "13.0.0",

View File

@@ -13,9 +13,9 @@
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.55.0",
"@types/node": "^24.3.1",
"@types/node": "^24.5.0",
"dotenv": "17.2.2",
"node-html-parser": "^7.0.1",
"totp-generator": "^1.0.0"
"totp-generator": "^2.0.0"
}
}

View File

@@ -130,7 +130,7 @@ test.describe('Admin', () => {
),
]);
await expect(page.getByText('Banned')).toBeVisible();
await expect(page.getByText('Banned').first()).toBeVisible();
await page.context().clearCookies();
@@ -156,7 +156,7 @@ test.describe('Admin', () => {
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
await page.getByRole('button', { name: 'Ban User' }).click();
await expect(page.getByText('Banned')).toBeVisible();
await expect(page.getByText('Banned').first()).toBeVisible();
// Now reactivate
await page.getByTestId('admin-reactivate-account-button').click();
@@ -199,6 +199,7 @@ test.describe('Admin', () => {
test('impersonate user flow', async ({ page }) => {
await page.getByTestId('admin-impersonate-button').click();
await expect(
page.getByRole('heading', { name: 'Impersonate User' }),
).toBeVisible();
@@ -394,14 +395,15 @@ async function filterAccounts(page: Page, email: string) {
}
async function selectAccount(page: Page, email: string) {
const link = page
.locator('tr', { hasText: email.split('@')[0] })
.locator('a');
await expect(async () => {
const link = page
.locator('tr', { hasText: email.split('@')[0] })
.locator('a');
await expect(link).toBeVisible();
await expect(link).toBeVisible();
await link.click();
await link.click();
await page.waitForURL(new RegExp(`/admin/accounts/[a-z0-9-]+`));
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
}).toPass();
}

View File

@@ -1,5 +1,4 @@
import { Page, expect } from '@playwright/test';
import { TOTP } from 'totp-generator';
import { Mailbox } from '../utils/mailbox';
@@ -50,7 +49,9 @@ export class AuthPageObject {
async submitMFAVerification(key: string) {
const period = 30;
const { otp } = TOTP.generate(key, {
const { TOTP } = await import('totp-generator');
const { otp } = await TOTP.generate(key, {
period,
});

View File

@@ -59,13 +59,13 @@ export class InvitationsPageObject {
navigateToMembers() {
return expect(async () => {
await this.page
.locator('a', {
hasText: 'Members',
})
.click();
.locator('a', {
hasText: 'Members',
})
.click();
await this.page.waitForURL('**/home/*/members');
}).toPass()
}).toPass();
}
async openInviteForm() {
@@ -127,6 +127,8 @@ export class InvitationsPageObject {
});
await Promise.all([click, response]);
console.log('Invitation accepted');
}
private getInviteForm() {

View File

@@ -84,7 +84,9 @@ test.describe('Team Invitation with MFA Flow', () => {
await invitations.acceptInvitation();
// Should be redirected to the team dashboard
await page.waitForURL(`/home/${teamSlug}`);
await page.waitForURL(`/home/${teamSlug}`, {
timeout: 5_000,
});
// Step 4: Verify membership was successful
// Open account selector to verify team is available

264
apps/web/AGENTS.md Normal file
View File

@@ -0,0 +1,264 @@
# Web Application Instructions
This file contains instructions specific to the main Next.js web application.
## Application Structure
### Route Organization
```
app/
├── (marketing)/ # Public pages (landing, blog, docs)
├── (auth)/ # Authentication pages
├── home/
│ ├── (user)/ # Personal account context
│ └── [account]/ # Team account context ([account] = team slug)
├── admin/ # Super admin section
└── api/ # API routes
```
Key Examples:
- Marketing layout: `app/(marketing)/layout.tsx`
- Personal dashboard: `app/home/(user)/page.tsx`
- Team workspace: `app/home/[account]/page.tsx`
- Admin section: `app/admin/page.tsx`
### Component Organization
- **Route-specific**: Use `_components/` directories
- **Route utilities**: Use `_lib/` for client, `_lib/server/` for server-side
- **Global components**: Root-level directories
Example:
- Team components: `app/home/[account]/_components/`
- Team server utils: `app/home/[account]/_lib/server/`
- Marketing components: `app/(marketing)/_components/`
## Data Fetching Strategy
**Quick Decision Framework:**
- **Server Components**: Default choice for initial data loading
- **Client Components**: For interactive features requiring hooks or real-time updates
- **Admin Client**: Only for bypassing RLS (rare cases - requires manual auth/authorization)
### 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} />;
}
```
**Key Insight**: Server Components automatically inherit RLS protection - no additional authorization checks needed!
### Client Components (Interactive) 🖱️
```typescript
'use client';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { useQuery } from '@tanstack/react-query';
function InteractiveNotes() {
const supabase = useSupabase();
const { data, isLoading } = useQuery({
queryKey: ['notes'],
queryFn: () => supabase.from('notes').select('*')
});
if (isLoading) return <Spinner />;
return <NotesList notes={data} />;
}
```
### Performance Optimization - Parallel Data Fetching 🚀
**Sequential (Slow) Pattern ❌**
```typescript
async function SlowDashboard() {
const userData = await loadUserData();
const notifications = await loadNotifications();
const metrics = await loadMetrics();
// Total time: sum of all requests
}
```
**Parallel (Optimized) Pattern ✅**
```typescript
async function FastDashboard() {
// Execute all requests simultaneously
const [userData, notifications, metrics] = await Promise.all([
loadUserData(),
loadNotifications(),
loadMetrics()
]);
// Total time: longest single request
return <Dashboard user={userData} notifications={notifications} metrics={metrics} />;
}
```
**Performance Impact**: Parallel fetching can reduce page load time by 60-80% for multi-data pages!
## Authorization Patterns - Critical Understanding 🔐
### RLS-Protected Data Fetching (Standard) ✅
```typescript
async function getUserNotes(userId: string) {
const client = getSupabaseServerClient();
// RLS automatically ensures user can only access their own notes
// NO additional authorization checks needed!
const { data } = await client.from('notes').select('*').eq('user_id', userId); // RLS validates this automatically
return data;
}
```
### Admin Client Usage (Dangerous - Rare Cases Only) ⚠️
```typescript
async function adminGetUserNotes(userId: string) {
const adminClient = getSupabaseServerAdminClient();
// CRITICAL: Manual authorization required - bypasses RLS!
const currentUser = await getCurrentUser();
if (!(await isSuperAdmin(currentUser))) {
throw new Error('Unauthorized: Admin access required');
}
// Additional validation: ensure current admin isn't targeting themselves
if (currentUser.id === userId) {
throw new Error('Cannot perform admin action on own account');
}
// Now safe to proceed with admin privileges
const { data } = await adminClient
.from('notes')
.select('*')
.eq('user_id', userId);
return data;
}
```
**Rule of thumb**: If using standard Supabase client, trust RLS. If using admin client, validate everything manually.
## Internationalization
Always use `Trans` component from `@kit/ui/trans`:
```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" />,
}}
/>
```
### Adding New Languages
1. Add language code to `lib/i18n/i18n.settings.ts`
2. Create translation files in `public/locales/[new-language]/`
3. Copy structure from English files
Translation files: `public/locales/<locale>/<namespace>.json`
## Workspace Contexts 🏢
### Personal Account Context (`app/home/(user)`)
```tsx
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
function PersonalComponent() {
const { user, account } = useUserWorkspace();
// Personal account data
}
```
Context provider: `@packages/features/accounts/src/components/user-workspace-context-provider.tsx`
### Team Account Context (`app/home/[account]`)
```tsx
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
function TeamComponent() {
const { account, user, accounts } = useTeamAccountWorkspace();
// Team account data with permissions
}
```
Context provider: `@packages/features/team-accounts/src/components/team-account-workspace-context-provider.tsx`
## Key Configuration Files
- **Feature flags**: `config/feature-flags.config.ts`
- **i18n settings**: `lib/i18n/i18n.settings.ts`
- **Supabase config**: `supabase/config.toml`
- **Middleware**: `middleware.ts`
## Route Handlers (API Routes)
Use `enhanceRouteHandler` from `@packages/next/src/routes/index.ts`:
```typescript
import { enhanceRouteHandler } from '@kit/next/routes';
export const POST = enhanceRouteHandler(
async function ({ body, user, request }) {
// body is validated, user available if auth: true
return NextResponse.json({ success: true });
},
{
auth: true,
schema: ZodSchema,
},
);
```
## Security Guidelines 🛡️
### Authentication & Authorization
- Authentication already enforced by middleware
- Authorization handled by RLS at database level (in most cases)
- Avoid defensive code - use RLS instead
- When using the Supabase admin client, must enforce both authentication and authorization
### Passing data to the client
- **Never pass sensitive data** to Client Components
- **Never expose server environment variables** to client (unless prefixed with NEXT_PUBLIC)
- Always validate user input
### Super Admin Protection
For admin routes, use `AdminGuard` from `@packages/features/admin/src/components/admin-guard.tsx`:
```tsx
import { AdminGuard } from '@kit/admin/components/admin-guard';
export default AdminGuard(AdminPageComponent);
```

264
apps/web/CLAUDE.md Normal file
View File

@@ -0,0 +1,264 @@
# Web Application Instructions
This file contains instructions specific to the main Next.js web application.
## Application Structure
### Route Organization
```
app/
├── (marketing)/ # Public pages (landing, blog, docs)
├── (auth)/ # Authentication pages
├── home/
│ ├── (user)/ # Personal account context
│ └── [account]/ # Team account context ([account] = team slug)
├── admin/ # Super admin section
└── api/ # API routes
```
Key Examples:
- Marketing layout: `app/(marketing)/layout.tsx`
- Personal dashboard: `app/home/(user)/page.tsx`
- Team workspace: `app/home/[account]/page.tsx`
- Admin section: `app/admin/page.tsx`
### Component Organization
- **Route-specific**: Use `_components/` directories
- **Route utilities**: Use `_lib/` for client, `_lib/server/` for server-side
- **Global components**: Root-level directories
Example:
- Team components: `app/home/[account]/_components/`
- Team server utils: `app/home/[account]/_lib/server/`
- Marketing components: `app/(marketing)/_components/`
## Data Fetching Strategy
**Quick Decision Framework:**
- **Server Components**: Default choice for initial data loading
- **Client Components**: For interactive features requiring hooks or real-time updates
- **Admin Client**: Only for bypassing RLS (rare cases - requires manual auth/authorization)
### 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} />;
}
```
**Key Insight**: Server Components automatically inherit RLS protection - no additional authorization checks needed!
### Client Components (Interactive) 🖱️
```typescript
'use client';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { useQuery } from '@tanstack/react-query';
function InteractiveNotes() {
const supabase = useSupabase();
const { data, isLoading } = useQuery({
queryKey: ['notes'],
queryFn: () => supabase.from('notes').select('*')
});
if (isLoading) return <Spinner />;
return <NotesList notes={data} />;
}
```
### Performance Optimization - Parallel Data Fetching 🚀
**Sequential (Slow) Pattern ❌**
```typescript
async function SlowDashboard() {
const userData = await loadUserData();
const notifications = await loadNotifications();
const metrics = await loadMetrics();
// Total time: sum of all requests
}
```
**Parallel (Optimized) Pattern ✅**
```typescript
async function FastDashboard() {
// Execute all requests simultaneously
const [userData, notifications, metrics] = await Promise.all([
loadUserData(),
loadNotifications(),
loadMetrics()
]);
// Total time: longest single request
return <Dashboard user={userData} notifications={notifications} metrics={metrics} />;
}
```
**Performance Impact**: Parallel fetching can reduce page load time by 60-80% for multi-data pages!
## Authorization Patterns - Critical Understanding 🔐
### RLS-Protected Data Fetching (Standard) ✅
```typescript
async function getUserNotes(userId: string) {
const client = getSupabaseServerClient();
// RLS automatically ensures user can only access their own notes
// NO additional authorization checks needed!
const { data } = await client.from('notes').select('*').eq('user_id', userId); // RLS validates this automatically
return data;
}
```
### Admin Client Usage (Dangerous - Rare Cases Only) ⚠️
```typescript
async function adminGetUserNotes(userId: string) {
const adminClient = getSupabaseServerAdminClient();
// CRITICAL: Manual authorization required - bypasses RLS!
const currentUser = await getCurrentUser();
if (!(await isSuperAdmin(currentUser))) {
throw new Error('Unauthorized: Admin access required');
}
// Additional validation: ensure current admin isn't targeting themselves
if (currentUser.id === userId) {
throw new Error('Cannot perform admin action on own account');
}
// Now safe to proceed with admin privileges
const { data } = await adminClient
.from('notes')
.select('*')
.eq('user_id', userId);
return data;
}
```
**Rule of thumb**: If using standard Supabase client, trust RLS. If using admin client, validate everything manually.
## Internationalization
Always use `Trans` component from `@kit/ui/trans`:
```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" />,
}}
/>
```
### Adding New Languages
1. Add language code to `lib/i18n/i18n.settings.ts`
2. Create translation files in `public/locales/[new-language]/`
3. Copy structure from English files
Translation files: `public/locales/<locale>/<namespace>.json`
## Workspace Contexts 🏢
### Personal Account Context (`app/home/(user)`)
```tsx
import { useUserWorkspace } from '@kit/accounts/hooks/use-user-workspace';
function PersonalComponent() {
const { user, account } = useUserWorkspace();
// Personal account data
}
```
Context provider: `@packages/features/accounts/src/components/user-workspace-context-provider.tsx`
### Team Account Context (`app/home/[account]`)
```tsx
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
function TeamComponent() {
const { account, user, accounts } = useTeamAccountWorkspace();
// Team account data with permissions
}
```
Context provider: `@packages/features/team-accounts/src/components/team-account-workspace-context-provider.tsx`
## Key Configuration Files
- **Feature flags**: `config/feature-flags.config.ts`
- **i18n settings**: `lib/i18n/i18n.settings.ts`
- **Supabase config**: `supabase/config.toml`
- **Middleware**: `middleware.ts`
## Route Handlers (API Routes)
Use `enhanceRouteHandler` from `@packages/next/src/routes/index.ts`:
```typescript
import { enhanceRouteHandler } from '@kit/next/routes';
export const POST = enhanceRouteHandler(
async function ({ body, user, request }) {
// body is validated, user available if auth: true
return NextResponse.json({ success: true });
},
{
auth: true,
schema: ZodSchema,
},
);
```
## Security Guidelines 🛡️
### Authentication & Authorization
- Authentication already enforced by middleware
- Authorization handled by RLS at database level (in most cases)
- Avoid defensive code - use RLS instead
- When using the Supabase admin client, must enforce both authentication and authorization
### Passing data to the client
- **Never pass sensitive data** to Client Components
- **Never expose server environment variables** to client (unless prefixed with NEXT_PUBLIC)
- Always validate user input
### Super Admin Protection
For admin routes, use `AdminGuard` from `@packages/features/admin/src/components/admin-guard.tsx`:
```tsx
import { AdminGuard } from '@kit/admin/components/admin-guard';
export default AdminGuard(AdminPageComponent);
```

View File

@@ -0,0 +1,3 @@
import { GlobalLoader } from '@kit/ui/global-loader';
export default GlobalLoader;

View File

@@ -32,7 +32,7 @@
},
"dependencies": {
"@edge-csrf/nextjs": "2.5.3-cloudflare-rc1",
"@hookform/resolvers": "^5.2.1",
"@hookform/resolvers": "^5.2.2",
"@kit/accounts": "workspace:*",
"@kit/admin": "workspace:*",
"@kit/analytics": "workspace:*",
@@ -53,15 +53,15 @@
"@kit/ui": "workspace:*",
"@makerkit/data-loader-supabase-core": "^0.0.10",
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
"@marsidev/react-turnstile": "^1.3.0",
"@marsidev/react-turnstile": "^1.3.1",
"@nosecone/next": "1.0.0-beta.11",
"@radix-ui/react-icons": "^1.3.2",
"@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",
"date-fns": "^4.1.0",
"lucide-react": "^0.542.0",
"next": "15.5.2",
"lucide-react": "^0.544.0",
"next": "15.5.3",
"next-sitemap": "^4.2.3",
"next-themes": "0.4.6",
"react": "19.1.1",
@@ -76,16 +76,16 @@
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@next/bundle-analyzer": "15.5.2",
"@next/bundle-analyzer": "15.5.3",
"@tailwindcss/postcss": "^4.1.13",
"@types/node": "^24.3.1",
"@types/react": "19.1.12",
"@types/node": "^24.5.0",
"@types/react": "19.1.13",
"@types/react-dom": "19.1.9",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"cssnano": "^7.1.1",
"pino-pretty": "13.0.0",
"prettier": "^3.6.2",
"supabase": "2.39.2",
"supabase": "2.40.7",
"tailwindcss": "4.1.13",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.9.2"

292
apps/web/supabase/AGENTS.md Normal file
View File

@@ -0,0 +1,292 @@
# Supabase Database Schema Management
This file contains guidance for working with database schemas, migrations, and Supabase development workflows.
## Schema Organization
Schemas are organized in numbered files in the `schemas/` directory. Numbers are used to sort dependencies.
## Schema Development Workflow
### 1. Creating New Schema Files
```bash
# Create new schema file
touch schemas/15-my-new-feature.sql
# Apply changes and create migration
pnpm --filter web run supabase:db:diff -f my-new-feature
# Restart Supabase with fresh schema
pnpm supabase:web:reset
# Generate TypeScript types
pnpm supabase:web:typegen
```
### 2. Modifying Existing Schemas
```bash
# Edit schema file (e.g., schemas/03-accounts.sql)
# Make your changes...
# Create migration for changes
pnpm --filter web run supabase:db:diff -f update-accounts
# Apply and test
pnpm supabase:web:reset
pnpm supabase:web:typegen
```
## Security First Patterns
## Add permissions (if any)
```sql
ALTER TYPE public.app_permissions ADD VALUE 'notes.manage';
COMMIT;
```
### Table Creation 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,
-- ...
primary key (id)
);
-- CRITICAL: Always enable RLS
alter table "public"."notes" enable row level security;
-- Revoke default permissions
revoke all on public.notes from authenticated, service_role;
-- Grant specific permissions
grant select, insert, update, delete on table public.notes to authenticated;
-- Add 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 (
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
);
create policy "notes_update" on public.notes for update
to authenticated using (
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
)
with check (
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
);
create policy "notes_delete" on public.notes for delete
to authenticated using (
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
);
```
### Storage Bucket Policies
```sql
-- Create storage bucket
insert into storage.buckets (id, name, public)
values ('documents', 'documents', false);
-- RLS policy for storage
create policy documents_policy on storage.objects for all using (
bucket_id = 'documents'
and (
-- File belongs to user's account
kit.get_storage_filename_as_uuid(name) = auth.uid()
or
-- User has access to the account
public.has_role_on_account(kit.get_storage_filename_as_uuid(name))
)
)
with check (
bucket_id = 'documents'
and (
kit.get_storage_filename_as_uuid(name) = auth.uid()
or
public.has_permission(
auth.uid(),
kit.get_storage_filename_as_uuid(name),
'files.upload'::app_permissions
)
)
);
```
## Function Creation Patterns
### Safe Security Definer Functions
```sql
-- NEVER create security definer functions without explicit access controls
create or replace function public.create_team_account(account_name text)
returns public.accounts
language plpgsql
security definer -- Elevated privileges
set search_path = '' -- Prevent SQL injection
as $$
declare
new_account public.accounts;
begin
-- CRITICAL: Validate permissions first
if not public.is_set('enable_team_accounts') then
raise exception 'Team accounts are not enabled';
end if;
-- Additional validation can go here
if length(account_name) < 3 then
raise exception 'Account name must be at least 3 characters';
end if;
-- Now safe to proceed with elevated privileges
insert into public.accounts (name, is_personal_account)
values (account_name, false)
returning * into new_account;
return new_account;
end;
$$;
-- Grant to authenticated users only
grant execute on function public.create_team_account(text) to authenticated;
```
### Security Invoker Functions (Safer)
```sql
-- Preferred: Functions that inherit RLS policies
create or replace function public.get_account_notes(target_account_id uuid)
returns setof public.notes
language plpgsql
security invoker -- Inherits caller's permissions (RLS applies)
set search_path = ''
as $$
begin
-- RLS policies will automatically restrict results
return query
select * from public.notes
where account_id = target_account_id
order by created_at desc;
end;
$$;
grant execute on function public.get_account_notes(uuid) to authenticated;
```
### Safe Column Additions
```sql
-- Safe: Add nullable columns
alter table public.accounts
add column if not exists description text;
-- Safe: Add columns with defaults
alter table public.accounts
add column if not exists is_verified boolean default false not null;
-- Unsafe: Adding non-null columns without defaults
-- alter table public.accounts add column required_field text not null; -- DON'T DO THIS
```
### Index Management
```sql
-- Create indexes concurrently for large tables
create index concurrently if not exists ix_accounts_created_at
on public.accounts (created_at desc);
-- Drop unused indexes
drop index if exists ix_old_unused_index;
```
## Testing Database Changes
### Local Testing
```bash
# Test with fresh database
pnpm supabase:web:reset
# Test your changes
pnpm run supabase:web:test
```
## Type Generation
### After Schema Changes
```bash
# Generate types after any schema changes
pnpm supabase:web:typegen
# Types are generated to src/lib/supabase/database.types.ts
# Reset DB
pnpm supabase:web:reset
```
### Using Generated Types
```typescript
import { Enums, Tables } from '@kit/supabase/database';
// Table types
type Account = Tables<'accounts'>;
type Note = Tables<'notes'>;
// Enum types
type AppPermission = Enums<'app_permissions'>;
// Insert types
type AccountInsert = Tables<'accounts'>['Insert'];
type AccountUpdate = Tables<'accounts'>['Update'];
// Use in functions
async function createNote(data: Tables<'notes'>['Insert']) {
const { data: note, error } = await supabase
.from('notes')
.insert(data)
.select()
.single();
return note;
}
```
## Common Schema Patterns
### Audit Trail
Add triggers if the properties exist and are appropriate:
- `public.trigger_set_timestamps()` - for tables with `created_at` and `updated_at`
columns
- `public.trigger_set_user_tracking()` - for tables with `created_by` and `updated_by`
columns
### Useful Commands
```bash
# View migration status
pnpm --filter web supabase migration list
# Reset database completely
pnpm supabase:web:reset
# Generate migration from schema diff
pnpm --filter web run supabase:db:diff -f migration-name
# Apply specific migration
pnpm --filter web supabase migration up --include-schemas public
```

292
apps/web/supabase/CLAUDE.md Normal file
View File

@@ -0,0 +1,292 @@
# Supabase Database Schema Management
This file contains guidance for working with database schemas, migrations, and Supabase development workflows.
## Schema Organization
Schemas are organized in numbered files in the `schemas/` directory. Numbers are used to sort dependencies.
## Schema Development Workflow
### 1. Creating New Schema Files
```bash
# Create new schema file
touch schemas/15-my-new-feature.sql
# Apply changes and create migration
pnpm --filter web run supabase:db:diff -f my-new-feature
# Restart Supabase with fresh schema
pnpm supabase:web:reset
# Generate TypeScript types
pnpm supabase:web:typegen
```
### 2. Modifying Existing Schemas
```bash
# Edit schema file (e.g., schemas/03-accounts.sql)
# Make your changes...
# Create migration for changes
pnpm --filter web run supabase:db:diff -f update-accounts
# Apply and test
pnpm supabase:web:reset
pnpm supabase:web:typegen
```
## Security First Patterns
## Add permissions (if any)
```sql
ALTER TYPE public.app_permissions ADD VALUE 'notes.manage';
COMMIT;
```
### Table Creation 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,
-- ...
primary key (id)
);
-- CRITICAL: Always enable RLS
alter table "public"."notes" enable row level security;
-- Revoke default permissions
revoke all on public.notes from authenticated, service_role;
-- Grant specific permissions
grant select, insert, update, delete on table public.notes to authenticated;
-- Add 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 (
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
);
create policy "notes_update" on public.notes for update
to authenticated using (
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
)
with check (
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
);
create policy "notes_delete" on public.notes for delete
to authenticated using (
public.has_permission(auth.uid(), account_id, 'notes.manage'::app_permissions)
);
```
### Storage Bucket Policies
```sql
-- Create storage bucket
insert into storage.buckets (id, name, public)
values ('documents', 'documents', false);
-- RLS policy for storage
create policy documents_policy on storage.objects for all using (
bucket_id = 'documents'
and (
-- File belongs to user's account
kit.get_storage_filename_as_uuid(name) = auth.uid()
or
-- User has access to the account
public.has_role_on_account(kit.get_storage_filename_as_uuid(name))
)
)
with check (
bucket_id = 'documents'
and (
kit.get_storage_filename_as_uuid(name) = auth.uid()
or
public.has_permission(
auth.uid(),
kit.get_storage_filename_as_uuid(name),
'files.upload'::app_permissions
)
)
);
```
## Function Creation Patterns
### Safe Security Definer Functions
```sql
-- NEVER create security definer functions without explicit access controls
create or replace function public.create_team_account(account_name text)
returns public.accounts
language plpgsql
security definer -- Elevated privileges
set search_path = '' -- Prevent SQL injection
as $$
declare
new_account public.accounts;
begin
-- CRITICAL: Validate permissions first
if not public.is_set('enable_team_accounts') then
raise exception 'Team accounts are not enabled';
end if;
-- Additional validation can go here
if length(account_name) < 3 then
raise exception 'Account name must be at least 3 characters';
end if;
-- Now safe to proceed with elevated privileges
insert into public.accounts (name, is_personal_account)
values (account_name, false)
returning * into new_account;
return new_account;
end;
$$;
-- Grant to authenticated users only
grant execute on function public.create_team_account(text) to authenticated;
```
### Security Invoker Functions (Safer)
```sql
-- Preferred: Functions that inherit RLS policies
create or replace function public.get_account_notes(target_account_id uuid)
returns setof public.notes
language plpgsql
security invoker -- Inherits caller's permissions (RLS applies)
set search_path = ''
as $$
begin
-- RLS policies will automatically restrict results
return query
select * from public.notes
where account_id = target_account_id
order by created_at desc;
end;
$$;
grant execute on function public.get_account_notes(uuid) to authenticated;
```
### Safe Column Additions
```sql
-- Safe: Add nullable columns
alter table public.accounts
add column if not exists description text;
-- Safe: Add columns with defaults
alter table public.accounts
add column if not exists is_verified boolean default false not null;
-- Unsafe: Adding non-null columns without defaults
-- alter table public.accounts add column required_field text not null; -- DON'T DO THIS
```
### Index Management
```sql
-- Create indexes concurrently for large tables
create index concurrently if not exists ix_accounts_created_at
on public.accounts (created_at desc);
-- Drop unused indexes
drop index if exists ix_old_unused_index;
```
## Testing Database Changes
### Local Testing
```bash
# Test with fresh database
pnpm supabase:web:reset
# Test your changes
pnpm run supabase:web:test
```
## Type Generation
### After Schema Changes
```bash
# Generate types after any schema changes
pnpm supabase:web:typegen
# Types are generated to src/lib/supabase/database.types.ts
# Reset DB
pnpm supabase:web:reset
```
### Using Generated Types
```typescript
import { Enums, Tables } from '@kit/supabase/database';
// Table types
type Account = Tables<'accounts'>;
type Note = Tables<'notes'>;
// Enum types
type AppPermission = Enums<'app_permissions'>;
// Insert types
type AccountInsert = Tables<'accounts'>['Insert'];
type AccountUpdate = Tables<'accounts'>['Update'];
// Use in functions
async function createNote(data: Tables<'notes'>['Insert']) {
const { data: note, error } = await supabase
.from('notes')
.insert(data)
.select()
.single();
return note;
}
```
## Common Schema Patterns
### Audit Trail
Add triggers if the properties exist and are appropriate:
- `public.trigger_set_timestamps()` - for tables with `created_at` and `updated_at`
columns
- `public.trigger_set_user_tracking()` - for tables with `created_by` and `updated_by`
columns
### Useful Commands
```bash
# View migration status
pnpm --filter web supabase migration list
# Reset database completely
pnpm supabase:web:reset
# Generate migration from schema diff
pnpm --filter web run supabase:db:diff -f migration-name
# Apply specific migration
pnpm --filter web supabase migration up --include-schemas public
```

View File

@@ -0,0 +1,42 @@
-- Triggers for accounts table
create trigger accounts_set_timestamps
before insert or update on public.accounts
for each row execute function public.trigger_set_timestamps();
create trigger accounts_set_user_tracking
before insert or update on public.accounts
for each row execute function public.trigger_set_user_tracking();
-- Triggers for accounts_memberships table
create trigger accounts_memberships_set_timestamps
before insert or update on public.accounts_memberships
for each row execute function public.trigger_set_timestamps();
create trigger accounts_memberships_set_user_tracking
before insert or update on public.accounts_memberships
for each row execute function public.trigger_set_user_tracking();
-- Triggers for invitations table
create trigger invitations_set_timestamps
before insert or update on public.invitations
for each row execute function public.trigger_set_timestamps();
-- Triggers for subscriptions table
create trigger subscriptions_set_timestamps
before insert or update on public.subscriptions
for each row execute function public.trigger_set_timestamps();
-- Triggers for subscription_items table
create trigger subscription_items_set_timestamps
before insert or update on public.subscription_items
for each row execute function public.trigger_set_timestamps();
-- Triggers for orders table
create trigger orders_set_timestamps
before insert or update on public.orders
for each row execute function public.trigger_set_timestamps();
-- Triggers for order_items table
create trigger order_items_set_timestamps
before insert or update on public.order_items
for each row execute function public.trigger_set_timestamps();

View File

@@ -77,6 +77,15 @@ create unique index unique_personal_account on public.accounts (primary_owner_us
where
is_personal_account = true;
-- Triggers for accounts table
create trigger accounts_set_timestamps
before insert or update on public.accounts
for each row execute function public.trigger_set_timestamps();
create trigger accounts_set_user_tracking
before insert or update on public.accounts
for each row execute function public.trigger_set_user_tracking();
-- RLS on the accounts table
-- UPDATE(accounts):
-- Team owners can update their accounts

View File

@@ -46,6 +46,15 @@ create index ix_accounts_memberships_user_id on public.accounts_memberships (use
create index ix_accounts_memberships_account_role on public.accounts_memberships (account_role);
-- Triggers for accounts_memberships table
create trigger accounts_memberships_set_timestamps
before insert or update on public.accounts_memberships
for each row execute function public.trigger_set_timestamps();
create trigger accounts_memberships_set_user_tracking
before insert or update on public.accounts_memberships
for each row execute function public.trigger_set_user_tracking();
-- Enable RLS on the accounts_memberships table
alter table public.accounts_memberships enable row level security;

View File

@@ -36,6 +36,11 @@ comment on column public.invitations.email is 'The email of the user being invit
-- Indexes on the invitations table
create index ix_invitations_account_id on public.invitations (account_id);
-- Triggers for invitations table
create trigger invitations_set_timestamps
before insert or update on public.invitations
for each row execute function public.trigger_set_timestamps();
-- Revoke all on invitations table from authenticated and service_role
revoke all on public.invitations
from

View File

@@ -69,6 +69,11 @@ select
-- Indexes on the subscriptions table
create index ix_subscriptions_account_id on public.subscriptions (account_id);
-- Triggers for subscriptions table
create trigger subscriptions_set_timestamps
before insert or update on public.subscriptions
for each row execute function public.trigger_set_timestamps();
-- Enable RLS on subscriptions table
alter table public.subscriptions enable row level security;
@@ -314,6 +319,11 @@ delete on table public.subscription_items to service_role;
-- Indexes on the subscription_items table
create index ix_subscription_items_subscription_id on public.subscription_items (subscription_id);
-- Triggers for subscription_items table
create trigger subscription_items_set_timestamps
before insert or update on public.subscription_items
for each row execute function public.trigger_set_timestamps();
-- RLS
alter table public.subscription_items enable row level security;

View File

@@ -55,6 +55,11 @@ delete on table public.orders to service_role;
-- Indexes on the orders table
create index ix_orders_account_id on public.orders (account_id);
-- Triggers for orders table
create trigger orders_set_timestamps
before insert or update on public.orders
for each row execute function public.trigger_set_timestamps();
-- RLS
alter table public.orders enable row level security;
@@ -130,6 +135,11 @@ grant insert, update, delete on table public.order_items to service_role;
-- Indexes on the order_items table
create index ix_order_items_order_id on public.order_items (order_id);
-- Triggers for order_items table
create trigger order_items_set_timestamps
before insert or update on public.order_items
for each row execute function public.trigger_set_timestamps();
-- RLS
alter table public.order_items enable row level security;

View File

@@ -0,0 +1,155 @@
BEGIN;
create extension "basejump-supabase_test_helpers" version '0.0.6';
select plan(12);
--- Test the trigger_set_timestamps function on all tables
--- This test verifies that created_at and updated_at are properly set on insert
--- Create test users
select tests.create_supabase_user('trigger_test_user1', 'test1@example.com');
-- Authenticate as test user
select makerkit.authenticate_as('trigger_test_user1');
------------
--- Test accounts table timestamp triggers - INSERT
------------
INSERT INTO public.accounts (name, is_personal_account)
VALUES ('Test Account', false);
SELECT ok(
(SELECT created_at IS NOT NULL FROM public.accounts WHERE name = 'Test Account'),
'accounts: created_at should be set automatically on insert'
);
SELECT ok(
(SELECT updated_at IS NOT NULL FROM public.accounts WHERE name = 'Test Account'),
'accounts: updated_at should be set automatically on insert'
);
SELECT ok(
(SELECT created_at = updated_at FROM public.accounts WHERE name = 'Test Account'),
'accounts: created_at should equal updated_at on insert'
);
------------
--- Test invitations table timestamp triggers - INSERT
------------
-- Create a team account for invitation testing
INSERT INTO public.accounts (name, is_personal_account)
VALUES ('Invitation Test Team', false);
-- Test invitation insert
INSERT INTO public.invitations (email, account_id, invited_by, role, invite_token, expires_at)
VALUES (
'invitee@example.com',
(SELECT id FROM public.accounts WHERE name = 'Invitation Test Team'),
tests.get_supabase_uid('trigger_test_user1'),
'member',
'test-token-123',
now() + interval '7 days'
);
SELECT ok(
(SELECT created_at IS NOT NULL FROM public.invitations WHERE email = 'invitee@example.com'),
'invitations: created_at should be set automatically on insert'
);
SELECT ok(
(SELECT updated_at IS NOT NULL FROM public.invitations WHERE email = 'invitee@example.com'),
'invitations: updated_at should be set automatically on insert'
);
SELECT ok(
(SELECT created_at = updated_at FROM public.invitations WHERE email = 'invitee@example.com'),
'invitations: created_at should equal updated_at on insert'
);
------------
--- Test subscriptions table timestamp triggers - INSERT (service_role required)
------------
set role service_role;
-- Create billing customer first
INSERT INTO public.billing_customers (account_id, provider, customer_id, email)
VALUES (
(SELECT id FROM public.accounts WHERE name = 'Invitation Test Team'),
'stripe',
'cus_test123',
'billing@example.com'
);
-- Test subscription insert
INSERT INTO public.subscriptions (
id, account_id, billing_customer_id, status, active, billing_provider,
cancel_at_period_end, currency, period_starts_at, period_ends_at
)
VALUES (
'sub_test123',
(SELECT id FROM public.accounts WHERE name = 'Invitation Test Team'),
(SELECT id FROM public.billing_customers WHERE customer_id = 'cus_test123'),
'active',
true,
'stripe',
false,
'USD',
now(),
now() + interval '1 month'
);
SELECT ok(
(SELECT created_at IS NOT NULL FROM public.subscriptions WHERE id = 'sub_test123'),
'subscriptions: created_at should be set automatically on insert'
);
SELECT ok(
(SELECT updated_at IS NOT NULL FROM public.subscriptions WHERE id = 'sub_test123'),
'subscriptions: updated_at should be set automatically on insert'
);
SELECT ok(
(SELECT created_at = updated_at FROM public.subscriptions WHERE id = 'sub_test123'),
'subscriptions: created_at should equal updated_at on insert'
);
------------
--- Test subscription_items table timestamp triggers - INSERT
------------
-- Test subscription_item insert
INSERT INTO public.subscription_items (
id, subscription_id, product_id, variant_id, type, quantity, interval, interval_count
)
VALUES (
'si_test123',
'sub_test123',
'prod_test123',
'var_test123',
'flat',
1,
'month',
1
);
SELECT ok(
(SELECT created_at IS NOT NULL FROM public.subscription_items WHERE id = 'si_test123'),
'subscription_items: created_at should be set automatically on insert'
);
SELECT ok(
(SELECT updated_at IS NOT NULL FROM public.subscription_items WHERE id = 'si_test123'),
'subscription_items: updated_at should be set automatically on insert'
);
SELECT ok(
(SELECT created_at = updated_at FROM public.subscription_items WHERE id = 'si_test123'),
'subscription_items: created_at should equal updated_at on insert'
);
SELECT * FROM finish();
ROLLBACK;

View File

@@ -0,0 +1,43 @@
BEGIN;
create extension "basejump-supabase_test_helpers" version '0.0.6';
select plan(3);
--- Test the trigger_set_user_tracking function on accounts table
--- This test verifies that created_by and updated_by are properly set on insert
--- Create test users
select tests.create_supabase_user('user_tracking_test1', 'tracking1@example.com');
------------
--- Test accounts table user tracking triggers - INSERT
------------
-- Authenticate as first user for insert
select makerkit.authenticate_as('user_tracking_test1');
-- Test INSERT: created_by and updated_by should be set to current user
INSERT INTO public.accounts (name, is_personal_account)
VALUES ('User Tracking Test Account', false);
SELECT ok(
(SELECT created_by = tests.get_supabase_uid('user_tracking_test1')
FROM public.accounts WHERE name = 'User Tracking Test Account'),
'accounts: created_by should be set to current user on insert'
);
SELECT ok(
(SELECT updated_by = tests.get_supabase_uid('user_tracking_test1')
FROM public.accounts WHERE name = 'User Tracking Test Account'),
'accounts: updated_by should be set to current user on insert'
);
SELECT ok(
(SELECT created_by = updated_by
FROM public.accounts WHERE name = 'User Tracking Test Account'),
'accounts: created_by should equal updated_by on insert'
);
SELECT * FROM finish();
ROLLBACK;

View File

@@ -1,6 +1,6 @@
{
"name": "next-supabase-saas-kit-turbo",
"version": "2.13.1",
"version": "2.14.0",
"private": true,
"sideEffects": false,
"engines": {

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

4112
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,11 +13,11 @@
"format": "prettier --check \"**/*.{js,json}\""
},
"dependencies": {
"@next/eslint-plugin-next": "15.5.2",
"@next/eslint-plugin-next": "15.5.3",
"@types/eslint": "9.6.1",
"eslint-config-next": "15.5.2",
"eslint-config-next": "15.5.3",
"eslint-config-turbo": "^2.5.6",
"typescript-eslint": "8.42.0"
"typescript-eslint": "8.44.0"
},
"devDependencies": {
"@kit/prettier-config": "workspace:*",