refactor: consolidate AGENTS.md and CLAUDE.md files, update tech stac… (#444)
* refactor: consolidate AGENTS.md and CLAUDE.md files, update tech stack and architecture details - Merged content from CLAUDE.md into AGENTS.md for better organization. - Updated tech stack section to reflect the current technologies used, including Next.js, Supabase, and Tailwind CSS. - Enhanced monorepo structure documentation with detailed directory purposes. - Streamlined multi-tenant architecture explanation and essential commands. - Added key patterns for naming conventions and server actions. - Removed outdated agent files related to Playwright and PostgreSQL, ensuring a cleaner codebase. - Bumped version to 2.23.7 to reflect changes.
This commit is contained in:
committed by
GitHub
parent
bebd56238b
commit
cfa137795b
@@ -1,105 +1 @@
|
||||
# @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,
|
||||
});
|
||||
```
|
||||
@AGENTS.md
|
||||
|
||||
@@ -1,289 +1 @@
|
||||
# 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');
|
||||
}
|
||||
```
|
||||
@AGENTS.md
|
||||
|
||||
@@ -49,7 +49,9 @@ export async function initializeI18nClient(
|
||||
cookieMinutes: 60 * 24 * 365, // 1 year
|
||||
cookieOptions: {
|
||||
sameSite: 'lax',
|
||||
secure: typeof window !== 'undefined' && window.location.protocol === 'https:',
|
||||
secure:
|
||||
typeof window !== 'undefined' &&
|
||||
window.location.protocol === 'https:',
|
||||
path: '/',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,66 +1 @@
|
||||
# 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,
|
||||
});
|
||||
```
|
||||
@AGENTS.md
|
||||
|
||||
@@ -1,478 +1,58 @@
|
||||
# Next.js Utilities Instructions
|
||||
# Next.js Utilities
|
||||
|
||||
This file contains instructions for working with Next.js utilities including server actions and route handlers.
|
||||
## Quick Reference
|
||||
|
||||
| Function | Import | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `enhanceAction` | `@kit/next/actions` | Server actions with auth + validation |
|
||||
| `enhanceRouteHandler` | `@kit/next/routes` | API routes with auth + validation |
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Don't use Server Actions for data-fetching, use for mutations only
|
||||
- Best Practice: Keep actions light, move business logic to ad-hoc services
|
||||
- Authorization logic must be defined in RLS and DB, not Server Actions or application code (unless using the admin client, use sparinlgy!)
|
||||
- Do not expose sensitive data
|
||||
- Log async operations
|
||||
- Validate body with Zod
|
||||
- Use 'use server' at the top of the file. No need for 'server only';
|
||||
- Server Actions for mutations only, not data-fetching
|
||||
- Keep actions light - move business logic to services
|
||||
- Authorization via RLS, not application code
|
||||
- Use `'use server'` at top of file
|
||||
- Always validate with Zod schema
|
||||
|
||||
## Server Actions Implementation
|
||||
## Skills
|
||||
|
||||
Always use `enhanceAction` from `@packages/next/src/actions/index.ts`.
|
||||
For detailed implementation patterns:
|
||||
- `/server-action-builder` - Complete server action workflow
|
||||
|
||||
Define a schema:
|
||||
|
||||
```tsx
|
||||
import { z } from 'zod';
|
||||
|
||||
// Define your schema in its own file
|
||||
export 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'),
|
||||
});
|
||||
```
|
||||
|
||||
Then we define a service for crossing the network boundary:
|
||||
|
||||
```tsx
|
||||
import { CreateNoteSchema } from '../schemas/notes.schemas.ts';
|
||||
import * as z from 'zod';
|
||||
|
||||
export function createNotesService() {
|
||||
return new NotesService();
|
||||
}
|
||||
|
||||
class NotesService {
|
||||
createNote(data: z.infer<CreateNoteSchema>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Finally, we use Server Actions for exposing POST handlers:
|
||||
## Server Action Pattern
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { createNotesService } from '../notes.service.ts';
|
||||
|
||||
export const createNoteAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
// data is automatically validated against the schema
|
||||
// user is automatically authenticated if auth: true
|
||||
|
||||
const service = createNotesService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({
|
||||
userId: user.id,
|
||||
}, `Creating note...`);
|
||||
|
||||
const { data: note, error } = await service.createNote(data);
|
||||
|
||||
if (error) {
|
||||
logger.error({
|
||||
error: error.message
|
||||
}, `Error creating note`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info({
|
||||
noteId: note.id
|
||||
}, `Note successfully created`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
note
|
||||
};
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication (true by default, can omit)
|
||||
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) {
|
||||
// data: validated input data
|
||||
// user: authenticated user (if auth: true)
|
||||
|
||||
// data: validated, user: authenticated
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication (default: false)
|
||||
schema: MySchema, // Zod schema for validation (optional)
|
||||
// Additional options available
|
||||
auth: true,
|
||||
schema: MySchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Route Handlers (API Routes)
|
||||
|
||||
Use `enhanceRouteHandler` from `@packages/next/src/routes/index.ts`.
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Use when data must be exposed to externally
|
||||
- Use for receiving requests from external clients (such as webhooks)
|
||||
- Can be used for fetching data to client side fetchers (such as React Query) if cannot use client-side Supabase queries
|
||||
|
||||
### Usage
|
||||
## Route Handler Pattern
|
||||
|
||||
```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
|
||||
},
|
||||
{ auth: true, schema: MySchema },
|
||||
);
|
||||
```
|
||||
|
||||
## Revalidation
|
||||
|
||||
- Use `revalidatePath` for revalidating data after a migration.
|
||||
- Avoid calling `router.refresh()` or `router.push()` following a Server Action. Use `revalidatePath` and `redirect` from the server action instead.
|
||||
|
||||
## 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) {
|
||||
if (!isRedirectError(error)) {
|
||||
logger.error({ ...ctx, error }, 'Create note action failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateNoteSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
|
||||
### Server Action Redirects - Client Handling
|
||||
|
||||
When server actions call `redirect()`, it throws a special error that should NOT be treated as a failure:
|
||||
|
||||
```typescript
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
try {
|
||||
await myServerAction(formData);
|
||||
} catch (error) {
|
||||
// Don't treat redirects as errors
|
||||
if (!isRedirectError(error)) {
|
||||
// Handle actual errors
|
||||
toast.error('Something went wrong');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
### 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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
NB: When using `redirect`, we must handle it using `isRedirectError` otherwise we display an error after the server action succeeds
|
||||
|
||||
### 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;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 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,
|
||||
},
|
||||
);
|
||||
```
|
||||
- Use `revalidatePath` after mutations
|
||||
- Never use `router.refresh()` or `router.push()` after Server Actions
|
||||
|
||||
@@ -1,478 +1 @@
|
||||
# Next.js Utilities Instructions
|
||||
|
||||
This file contains instructions for working with Next.js utilities including server actions and route handlers.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Don't use Server Actions for data-fetching, use for mutations only
|
||||
- Best Practice: Keep actions light, move business logic to ad-hoc services
|
||||
- Authorization logic must be defined in RLS and DB, not Server Actions or application code (unless using the admin client, use sparinlgy!)
|
||||
- Do not expose sensitive data
|
||||
- Log async operations
|
||||
- Validate body with Zod
|
||||
- Use 'use server' at the top of the file. No need for 'server only';
|
||||
|
||||
## Server Actions Implementation
|
||||
|
||||
Always use `enhanceAction` from `@packages/next/src/actions/index.ts`.
|
||||
|
||||
Define a schema:
|
||||
|
||||
```tsx
|
||||
import { z } from 'zod';
|
||||
|
||||
// Define your schema in its own file
|
||||
export 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'),
|
||||
});
|
||||
```
|
||||
|
||||
Then we define a service for crossing the network boundary:
|
||||
|
||||
```tsx
|
||||
import { CreateNoteSchema } from '../schemas/notes.schemas.ts';
|
||||
import * as z from 'zod';
|
||||
|
||||
export function createNotesService() {
|
||||
return new NotesService();
|
||||
}
|
||||
|
||||
class NotesService {
|
||||
createNote(data: z.infer<CreateNoteSchema>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Finally, we use Server Actions for exposing POST handlers:
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { createNotesService } from '../notes.service.ts';
|
||||
|
||||
export const createNoteAction = enhanceAction(
|
||||
async function (data, user) {
|
||||
// data is automatically validated against the schema
|
||||
// user is automatically authenticated if auth: true
|
||||
|
||||
const service = createNotesService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({
|
||||
userId: user.id,
|
||||
}, `Creating note...`);
|
||||
|
||||
const { data: note, error } = await service.createNote(data);
|
||||
|
||||
if (error) {
|
||||
logger.error({
|
||||
error: error.message
|
||||
}, `Error creating note`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info({
|
||||
noteId: note.id
|
||||
}, `Note successfully created`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
note
|
||||
};
|
||||
},
|
||||
{
|
||||
auth: true, // Require authentication (true by default, can omit)
|
||||
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) {
|
||||
// data: validated input data
|
||||
// user: authenticated user (if auth: true)
|
||||
|
||||
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`.
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Use when data must be exposed to externally
|
||||
- Use for receiving requests from external clients (such as webhooks)
|
||||
- Can be used for fetching data to client side fetchers (such as React Query) if cannot use client-side Supabase queries
|
||||
|
||||
### Usage
|
||||
|
||||
```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
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Revalidation
|
||||
|
||||
- Use `revalidatePath` for revalidating data after a migration.
|
||||
- Avoid calling `router.refresh()` or `router.push()` following a Server Action. Use `revalidatePath` and `redirect` from the server action instead.
|
||||
|
||||
## 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) {
|
||||
if (!isRedirectError(error)) {
|
||||
logger.error({ ...ctx, error }, 'Create note action failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
schema: CreateNoteSchema,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
|
||||
### Server Action Redirects - Client Handling
|
||||
|
||||
When server actions call `redirect()`, it throws a special error that should NOT be treated as a failure:
|
||||
|
||||
```typescript
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
try {
|
||||
await myServerAction(formData);
|
||||
} catch (error) {
|
||||
// Don't treat redirects as errors
|
||||
if (!isRedirectError(error)) {
|
||||
// Handle actual errors
|
||||
toast.error('Something went wrong');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
### 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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
NB: When using `redirect`, we must handle it using `isRedirectError` otherwise we display an error after the server action succeeds
|
||||
|
||||
### 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;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 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,
|
||||
},
|
||||
);
|
||||
```
|
||||
@AGENTS.md
|
||||
|
||||
@@ -1,684 +1 @@
|
||||
# FeaturePolicy API - Registry-Based Policy System
|
||||
|
||||
A unified, registry-based foundation for implementing business rules across all Makerkit features.
|
||||
|
||||
## Overview
|
||||
|
||||
The FeaturePolicy API provides:
|
||||
|
||||
- **Registry-based architecture** - centralized policy management with IDs
|
||||
- **Configuration support** - policies can accept typed configuration objects
|
||||
- **Stage-aware evaluation** - policies can be filtered by execution stage
|
||||
- **Immutable contexts** for safe policy evaluation
|
||||
- **Customer extensibility** - easy to add custom policies without forking
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Register Policies
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
import { allow, createPolicyRegistry, definePolicy, deny } from '@kit/policies';
|
||||
|
||||
const registry = createPolicyRegistry();
|
||||
|
||||
// Register a basic policy
|
||||
registry.registerPolicy(
|
||||
definePolicy({
|
||||
id: 'email-validation',
|
||||
stages: ['preliminary', 'submission'],
|
||||
evaluate: async (context) => {
|
||||
if (!context.userEmail?.includes('@')) {
|
||||
return deny({
|
||||
code: 'INVALID_EMAIL_FORMAT',
|
||||
message: 'Invalid email format',
|
||||
remediation: 'Please provide a valid email address',
|
||||
});
|
||||
}
|
||||
return allow();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Register a configurable policy
|
||||
registry.registerPolicy(
|
||||
definePolicy({
|
||||
id: 'max-invitations',
|
||||
configSchema: z.object({
|
||||
maxInvitations: z.number().positive(),
|
||||
}),
|
||||
evaluate: async (context, config = { maxInvitations: 5 }) => {
|
||||
if (context.invitations.length > config.maxInvitations) {
|
||||
return deny({
|
||||
code: 'MAX_INVITATIONS_EXCEEDED',
|
||||
message: `Cannot invite more than ${config.maxInvitations} members`,
|
||||
remediation: `Reduce invitations to ${config.maxInvitations} or fewer`,
|
||||
});
|
||||
}
|
||||
return allow();
|
||||
},
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Use Policies from Registry
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createPoliciesFromRegistry,
|
||||
createPolicyEvaluator,
|
||||
createPolicyRegistry,
|
||||
} from '@kit/policies';
|
||||
|
||||
const registry = createPolicyRegistry();
|
||||
|
||||
// Load policies from registry
|
||||
const policies = await createPoliciesFromRegistry(registry, [
|
||||
'email-validation',
|
||||
'subscription-required',
|
||||
['max-invitations', { maxInvitations: 5 }], // with configuration
|
||||
]);
|
||||
|
||||
const evaluator = createPolicyEvaluator();
|
||||
const result = await evaluator.evaluatePolicies(policies, context, 'ALL');
|
||||
|
||||
if (!result.allowed) {
|
||||
console.log('Failed reasons:', result.reasons);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Group Policies with Complex Logic
|
||||
|
||||
```typescript
|
||||
// Basic group example
|
||||
const preliminaryGroup = {
|
||||
operator: 'ALL' as const,
|
||||
policies: [emailValidationPolicy, authenticationPolicy],
|
||||
};
|
||||
|
||||
const billingGroup = {
|
||||
operator: 'ANY' as const,
|
||||
policies: [subscriptionPolicy, trialPolicy],
|
||||
};
|
||||
|
||||
// Evaluate groups in sequence
|
||||
const result = await evaluator.evaluateGroups(
|
||||
[preliminaryGroup, billingGroup],
|
||||
context,
|
||||
);
|
||||
```
|
||||
|
||||
## Complex Group Flows
|
||||
|
||||
### Real-World Multi-Stage Team Invitation Flow
|
||||
|
||||
```typescript
|
||||
import { createPolicy, createPolicyEvaluator } from '@kit/policies';
|
||||
|
||||
// Complex business logic: (Authentication AND Email Validation) AND (Subscription OR Trial) AND Billing Limits
|
||||
async function validateTeamInvitation(context: InvitationContext) {
|
||||
const evaluator = createPolicyEvaluator();
|
||||
|
||||
// Stage 1: Authentication Requirements (ALL must pass)
|
||||
const authenticationGroup = {
|
||||
operator: 'ALL' as const,
|
||||
policies: [
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.userId
|
||||
? allow({ step: 'authenticated' })
|
||||
: deny('Authentication required'),
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.email.includes('@')
|
||||
? allow({ step: 'email-valid' })
|
||||
: deny('Valid email required'),
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.permissions.includes('invite')
|
||||
? allow({ step: 'permissions' })
|
||||
: deny('Insufficient permissions'),
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
// Stage 2: Subscription Validation (ANY sufficient - flexible billing)
|
||||
const subscriptionGroup = {
|
||||
operator: 'ANY' as const,
|
||||
policies: [
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.subscription?.active && ctx.subscription.plan === 'enterprise'
|
||||
? allow({ billing: 'enterprise' })
|
||||
: deny('Enterprise subscription required'),
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.subscription?.active && ctx.subscription.plan === 'pro'
|
||||
? allow({ billing: 'pro' })
|
||||
: deny('Pro subscription required'),
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.trial?.active && ctx.trial.daysRemaining > 0
|
||||
? allow({ billing: 'trial', daysLeft: ctx.trial.daysRemaining })
|
||||
: deny('Active trial required'),
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
// Stage 3: Final Constraints (ALL must pass)
|
||||
const constraintsGroup = {
|
||||
operator: 'ALL' as const,
|
||||
policies: [
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.team.memberCount < ctx.subscription?.maxMembers
|
||||
? allow({ constraint: 'member-limit' })
|
||||
: deny('Member limit exceeded'),
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.invitations.length <= 10
|
||||
? allow({ constraint: 'batch-size' })
|
||||
: deny('Cannot invite more than 10 members at once'),
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
// Execute all groups sequentially - ALL groups must pass
|
||||
const result = await evaluator.evaluateGroups(
|
||||
[authenticationGroup, subscriptionGroup, constraintsGroup],
|
||||
context,
|
||||
);
|
||||
|
||||
return {
|
||||
allowed: result.allowed,
|
||||
reasons: result.reasons,
|
||||
metadata: {
|
||||
stagesCompleted: result.results.length,
|
||||
authenticationPassed: result.results.some(
|
||||
(r) => r.metadata?.step === 'authenticated',
|
||||
),
|
||||
billingType: result.results.find((r) => r.metadata?.billing)?.metadata
|
||||
?.billing,
|
||||
constraintsChecked: result.results.some((r) => r.metadata?.constraint),
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Middleware-Style Policy Chain
|
||||
|
||||
```typescript
|
||||
// Simulate middleware pattern: Auth → Rate Limiting → Business Logic
|
||||
async function processApiRequest(context: ApiContext) {
|
||||
const evaluator = createPoliciesEvaluator();
|
||||
|
||||
// Layer 1: Security (ALL required)
|
||||
const securityLayer = {
|
||||
operator: 'ALL' as const,
|
||||
policies: [
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.apiKey && ctx.apiKey.length > 0
|
||||
? allow({ security: 'api-key-valid' })
|
||||
: deny('API key required'),
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.rateLimitRemaining > 0
|
||||
? allow({ security: 'rate-limit-ok' })
|
||||
: deny('Rate limit exceeded'),
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
!ctx.blacklisted
|
||||
? allow({ security: 'not-blacklisted' })
|
||||
: deny('Client is blacklisted'),
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
// Layer 2: Authorization (ANY sufficient - flexible access levels)
|
||||
const authorizationLayer = {
|
||||
operator: 'ANY' as const,
|
||||
policies: [
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.user.role === 'admin'
|
||||
? allow({ access: 'admin' })
|
||||
: deny('Admin access denied'),
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.user.permissions.includes(ctx.requestedResource)
|
||||
? allow({ access: 'resource-specific' })
|
||||
: deny('Resource access denied'),
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.user.subscription?.includes('api-access')
|
||||
? allow({ access: 'subscription-based' })
|
||||
: deny('Subscription access denied'),
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
// Layer 3: Business Rules (ALL required)
|
||||
const businessLayer = {
|
||||
operator: 'ALL' as const,
|
||||
policies: [
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.request.size <= ctx.maxRequestSize
|
||||
? allow({ business: 'size-valid' })
|
||||
: deny('Request too large'),
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.user.dailyQuota > ctx.user.dailyUsage
|
||||
? allow({ business: 'quota-available' })
|
||||
: deny('Daily quota exceeded'),
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
return evaluator.evaluateGroups(
|
||||
[securityLayer, authorizationLayer, businessLayer],
|
||||
context,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Complex Nested Logic with Short-Circuiting
|
||||
|
||||
```typescript
|
||||
// Complex scenario: (Premium User OR (Basic User AND Low Usage)) AND Security Checks
|
||||
async function validateFeatureAccess(context: FeatureContext) {
|
||||
const evaluator = createPoliciesEvaluator();
|
||||
|
||||
// Group 1: User Tier Logic - demonstrates complex OR conditions
|
||||
const userTierGroup = {
|
||||
operator: 'ANY' as const,
|
||||
policies: [
|
||||
// Premium users get immediate access
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.user.plan === 'premium'
|
||||
? allow({ tier: 'premium', reason: 'premium-user' })
|
||||
: deny('Not premium user'),
|
||||
),
|
||||
// Enterprise users get immediate access
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.user.plan === 'enterprise'
|
||||
? allow({ tier: 'enterprise', reason: 'enterprise-user' })
|
||||
: deny('Not enterprise user'),
|
||||
),
|
||||
// Basic users need additional validation (sub-group logic)
|
||||
createPolicy(async (ctx) => {
|
||||
if (ctx.user.plan !== 'basic') {
|
||||
return deny('Not basic user');
|
||||
}
|
||||
|
||||
// Simulate nested AND logic for basic users
|
||||
const basicUserRequirements = [
|
||||
ctx.user.monthlyUsage < 1000,
|
||||
ctx.user.accountAge > 30, // days
|
||||
!ctx.user.hasViolations,
|
||||
];
|
||||
|
||||
const allBasicRequirementsMet = basicUserRequirements.every(
|
||||
(req) => req,
|
||||
);
|
||||
|
||||
return allBasicRequirementsMet
|
||||
? allow({ tier: 'basic', reason: 'low-usage-basic-user' })
|
||||
: deny('Basic user requirements not met');
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
// Group 2: Security Requirements (ALL must pass)
|
||||
const securityGroup = {
|
||||
operator: 'ALL' as const,
|
||||
policies: [
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.user.emailVerified
|
||||
? allow({ security: 'email-verified' })
|
||||
: deny('Email verification required'),
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.user.twoFactorEnabled || ctx.user.plan === 'basic'
|
||||
? allow({ security: '2fa-compliant' })
|
||||
: deny('Two-factor authentication required for premium plans'),
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
!ctx.user.suspiciousActivity
|
||||
? allow({ security: 'activity-clean' })
|
||||
: deny('Suspicious activity detected'),
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
return evaluator.evaluateGroups([userTierGroup, securityGroup], context);
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic Policy Composition
|
||||
|
||||
```typescript
|
||||
// Dynamically compose policies based on context
|
||||
async function createContextAwarePolicyFlow(context: DynamicContext) {
|
||||
const evaluator = createPoliciesEvaluator();
|
||||
const groups = [];
|
||||
|
||||
// Always include base security
|
||||
const baseSecurityGroup = {
|
||||
operator: 'ALL' as const,
|
||||
policies: [
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.isAuthenticated ? allow() : deny('Authentication required'),
|
||||
),
|
||||
],
|
||||
};
|
||||
groups.push(baseSecurityGroup);
|
||||
|
||||
// Add user-type specific policies
|
||||
if (context.user.type === 'admin') {
|
||||
const adminGroup = {
|
||||
operator: 'ALL' as const,
|
||||
policies: [
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.user.adminLevel >= ctx.requiredAdminLevel
|
||||
? allow({ admin: 'level-sufficient' })
|
||||
: deny('Insufficient admin level'),
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.user.lastLogin > Date.now() - 24 * 60 * 60 * 1000 // 24 hours
|
||||
? allow({ admin: 'recent-login' })
|
||||
: deny('Admin must have logged in within 24 hours'),
|
||||
),
|
||||
],
|
||||
};
|
||||
groups.push(adminGroup);
|
||||
}
|
||||
|
||||
// Add feature-specific policies based on requested feature
|
||||
if (context.feature.requiresBilling) {
|
||||
const billingGroup = {
|
||||
operator: 'ANY' as const,
|
||||
policies: [
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.subscription?.active
|
||||
? allow({ billing: 'subscription' })
|
||||
: deny('Active subscription required'),
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.credits && ctx.credits > ctx.feature.creditCost
|
||||
? allow({ billing: 'credits' })
|
||||
: deny('Insufficient credits'),
|
||||
),
|
||||
],
|
||||
};
|
||||
groups.push(billingGroup);
|
||||
}
|
||||
|
||||
// Add rate limiting for high-impact features
|
||||
if (context.feature.highImpact) {
|
||||
const rateLimitGroup = {
|
||||
operator: 'ALL' as const,
|
||||
policies: [
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.rateLimit.remaining > 0
|
||||
? allow({ rateLimit: 'within-limits' })
|
||||
: deny('Rate limit exceeded for high-impact features'),
|
||||
),
|
||||
],
|
||||
};
|
||||
groups.push(rateLimitGroup);
|
||||
}
|
||||
|
||||
return evaluator.evaluateGroups(groups, context);
|
||||
}
|
||||
```
|
||||
|
||||
### Performance-Optimized Large Group Evaluation
|
||||
|
||||
```typescript
|
||||
// Handle large numbers of policies efficiently
|
||||
async function validateComplexBusinessRules(context: BusinessContext) {
|
||||
const evaluator = createPoliciesEvaluator({ maxCacheSize: 200 });
|
||||
|
||||
// Group policies by evaluation cost and criticality
|
||||
const criticalFastGroup = {
|
||||
operator: 'ALL' as const,
|
||||
policies: [
|
||||
// Fast critical checks first
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.isActive ? allow() : deny('Account inactive'),
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.hasPermission ? allow() : deny('No permission'),
|
||||
),
|
||||
createPolicy(async (ctx) =>
|
||||
!ctx.isBlocked ? allow() : deny('Account blocked'),
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
const businessLogicGroup = {
|
||||
operator: 'ANY' as const,
|
||||
policies: [
|
||||
// Complex business rules
|
||||
createPolicy(async (ctx) => {
|
||||
// Simulate complex calculation
|
||||
const score = await calculateRiskScore(ctx);
|
||||
return score < 0.8
|
||||
? allow({ risk: 'low' })
|
||||
: deny('High risk detected');
|
||||
}),
|
||||
createPolicy(async (ctx) => {
|
||||
// Simulate external API call
|
||||
const verification = await verifyWithThirdParty(ctx);
|
||||
return verification.success
|
||||
? allow({ external: 'verified' })
|
||||
: deny('External verification failed');
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
const finalValidationGroup = {
|
||||
operator: 'ALL' as const,
|
||||
policies: [
|
||||
// Final checks after complex logic
|
||||
createPolicy(async (ctx) =>
|
||||
ctx.complianceCheck ? allow() : deny('Compliance check failed'),
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
// Use staged evaluation for better performance
|
||||
const startTime = Date.now();
|
||||
|
||||
const result = await evaluator.evaluateGroups(
|
||||
[
|
||||
criticalFastGroup, // Fast critical checks first
|
||||
businessLogicGroup, // Complex logic only if critical checks pass
|
||||
finalValidationGroup, // Final validation
|
||||
],
|
||||
context,
|
||||
);
|
||||
|
||||
const evaluationTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
...result,
|
||||
performance: {
|
||||
evaluationTimeMs: evaluationTime,
|
||||
groupsEvaluated: result.results.length > 0 ? 3 : 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Helper functions for complex examples
|
||||
async function calculateRiskScore(context: any): Promise<number> {
|
||||
// Simulate complex risk calculation
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
return Math.random();
|
||||
}
|
||||
|
||||
async function verifyWithThirdParty(
|
||||
context: any,
|
||||
): Promise<{ success: boolean }> {
|
||||
// Simulate external API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
return { success: Math.random() > 0.2 };
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Configurable Policies
|
||||
|
||||
```typescript
|
||||
// Create policy factories for configuration
|
||||
const createMaxInvitationsPolicy = (maxInvitations: number) =>
|
||||
createPolicy(async (context) => {
|
||||
if (context.invitations.length > maxInvitations) {
|
||||
return deny({
|
||||
code: 'MAX_INVITATIONS_EXCEEDED',
|
||||
message: `Cannot invite more than ${maxInvitations} members`,
|
||||
remediation: `Reduce invitations to ${maxInvitations} or fewer`,
|
||||
});
|
||||
}
|
||||
return allow();
|
||||
});
|
||||
|
||||
// Use with different configurations
|
||||
const strictPolicy = createMaxInvitationsPolicy(1);
|
||||
const standardPolicy = createMaxInvitationsPolicy(5);
|
||||
const permissivePolicy = createMaxInvitationsPolicy(25);
|
||||
```
|
||||
|
||||
### Feature-Specific evaluators
|
||||
|
||||
```typescript
|
||||
// Create feature-specific evaluator with preset configurations
|
||||
export function createInvitationevaluator(
|
||||
preset: 'strict' | 'standard' | 'permissive',
|
||||
) {
|
||||
const configs = {
|
||||
strict: { maxInvitationsPerBatch: 1 },
|
||||
standard: { maxInvitationsPerBatch: 5 },
|
||||
permissive: { maxInvitationsPerBatch: 25 },
|
||||
};
|
||||
|
||||
const config = configs[preset];
|
||||
|
||||
return {
|
||||
async validateInvitations(context: InvitationContext) {
|
||||
const policies = [
|
||||
emailValidationPolicy,
|
||||
createMaxInvitationsPolicy(config.maxInvitationsPerBatch),
|
||||
subscriptionRequiredPolicy,
|
||||
paddleBillingPolicy,
|
||||
];
|
||||
|
||||
const evaluator = createPoliciesEvaluator();
|
||||
return evaluator.evaluatePolicies(policies, context, 'ALL');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
const evaluator = createInvitationevaluator('standard');
|
||||
const result = await evaluator.validateInvitations(context);
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
const result = await evaluator.evaluate();
|
||||
|
||||
if (!result.allowed) {
|
||||
result.reasons.forEach((reason) => {
|
||||
console.log(`Policy ${reason.policyId} failed:`);
|
||||
console.log(` Code: ${reason.code}`);
|
||||
console.log(` Message: ${reason.message}`);
|
||||
if (reason.remediation) {
|
||||
console.log(` Fix: ${reason.remediation}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 1. Register Complex Policy with Configuration
|
||||
|
||||
```typescript
|
||||
import { createPolicyRegistry, definePolicy } from '@kit/policies';
|
||||
|
||||
const registry = createPolicyRegistry();
|
||||
|
||||
const customConfigurablePolicy = definePolicy({
|
||||
id: 'custom-domain-check',
|
||||
configSchema: z.object({
|
||||
allowedDomains: z.array(z.string()),
|
||||
strictMode: z.boolean(),
|
||||
}),
|
||||
evaluate: async (context, config) => {
|
||||
const emailDomain = context.userEmail?.split('@')[1];
|
||||
|
||||
if (config?.strictMode && !config.allowedDomains.includes(emailDomain)) {
|
||||
return deny({
|
||||
code: 'DOMAIN_NOT_ALLOWED',
|
||||
message: `Email domain ${emailDomain} is not in the allowed list`,
|
||||
remediation: 'Use an email from an approved domain',
|
||||
});
|
||||
}
|
||||
|
||||
return allow();
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPolicy(customConfigurablePolicy);
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Group Operators
|
||||
|
||||
- **`ALL` (AND logic)**: All policies in the group must pass
|
||||
- **Short-circuits on first failure** for performance
|
||||
- Use for mandatory requirements where every condition must be met
|
||||
- Example: Authentication AND permissions AND rate limiting
|
||||
|
||||
- **`ANY` (OR logic)**: At least one policy in the group must pass
|
||||
- **Short-circuits on first success** for performance
|
||||
- Use for flexible requirements where multiple options are acceptable
|
||||
- Example: Premium subscription OR trial access OR admin override
|
||||
|
||||
### Group Evaluation Flow
|
||||
|
||||
1. **Sequential Group Processing**: Groups are evaluated in order
|
||||
2. **All Groups Must Pass**: If any group fails, entire evaluation fails
|
||||
3. **Short-Circuiting**: Stops on first group failure for performance
|
||||
4. **Metadata Preservation**: All policy results and metadata are collected
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- **Order groups by criticality**: Put fast, critical checks first
|
||||
- **Use caching**: Configure `maxCacheSize` for frequently used policies
|
||||
- **Group by evaluation cost**: Separate expensive operations
|
||||
- **Monitor evaluation time**: Track performance for optimization
|
||||
|
||||
## Stage-Aware Evaluation
|
||||
|
||||
Policies can be filtered by execution stage. This is useful for running a subset of policies depending on the situation:
|
||||
|
||||
```typescript
|
||||
// Only run preliminary checks
|
||||
const prelimResult = await evaluator.evaluate(
|
||||
registry,
|
||||
context,
|
||||
'ALL',
|
||||
'preliminary',
|
||||
);
|
||||
|
||||
// Run submission validation
|
||||
const submitResult = await evaluator.evaluate(
|
||||
registry,
|
||||
context,
|
||||
'ALL',
|
||||
'submission',
|
||||
);
|
||||
|
||||
// Run all applicable policies
|
||||
const fullResult = await evaluator.evaluate(registry, context, 'ALL');
|
||||
```
|
||||
@AGENTS.md
|
||||
|
||||
@@ -1,122 +1,51 @@
|
||||
# Database & Authentication Instructions
|
||||
# Database & Authentication
|
||||
|
||||
This file contains instructions for working with Supabase, database security, and authentication.
|
||||
## Skills
|
||||
|
||||
## Schemas and Migrations ⚠️
|
||||
For database work:
|
||||
- `/postgres-expert` - Schemas, RLS, migrations
|
||||
|
||||
**Critical Understanding**: Schema files are NOT automatically applied to the database!
|
||||
## Client Usage
|
||||
|
||||
- **Schemas** (`supabase/schemas/`) represent the desired database state (source of truth)
|
||||
- **Migrations** (`supabase/migrations/`) are the actual SQL commands that modify the database
|
||||
### Server Components (Preferred)
|
||||
|
||||
### The Required Workflow
|
||||
```typescript
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
1. **Edit schema file** (e.g., `supabase/schemas/18-projects.sql`)
|
||||
2. **Generate migration**: `pnpm --filter web supabase:db:diff -f migration_name`
|
||||
- This compares your schema against the current database and creates a migration
|
||||
3. **Apply migration**: `pnpm --filter web supabase migrations up`
|
||||
- This actually executes the SQL changes in the database
|
||||
|
||||
**⚠️ CRITICAL**: Editing a schema file alone does NOTHING to your database. You MUST generate and apply a migration for changes to take effect. Schema files are templates - migrations are the actual database operations.
|
||||
|
||||
## 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;
|
||||
const client = getSupabaseServerClient();
|
||||
const { data } = await client.from('table').select('*');
|
||||
// RLS automatically enforced
|
||||
```
|
||||
|
||||
### Security Definer Function - Safe Pattern ✅
|
||||
### Client Components
|
||||
|
||||
```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;
|
||||
```typescript
|
||||
'use client';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
-- Now safe to proceed with elevated privileges
|
||||
-- Your admin operation here
|
||||
END;
|
||||
$;
|
||||
const supabase = useSupabase();
|
||||
```
|
||||
|
||||
Only grant critical functions to `service_role`:
|
||||
### Admin Client (Use Sparingly)
|
||||
|
||||
```sql
|
||||
grant execute on public.dangerous_function to service_role;
|
||||
```typescript
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
// CRITICAL: Bypasses RLS - validate manually!
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
```
|
||||
|
||||
## Existing Helper Functions - Use These! 📚
|
||||
|
||||
**DO NOT recreate these functions - they already exist:**
|
||||
## Existing Helper Functions
|
||||
|
||||
```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
|
||||
public.has_role_on_account(account_id, role?)
|
||||
public.has_permission(user_id, account_id, permission)
|
||||
public.is_account_owner(account_id)
|
||||
public.has_active_subscription(account_id)
|
||||
public.is_team_member(account_id, user_id)
|
||||
public.is_super_admin()
|
||||
```
|
||||
|
||||
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)
|
||||
);
|
||||
```
|
||||
|
||||
- **Never modify database.types.ts**: Instead, use the Supabase CLI using our package.json scripts to re-generate the types after resetting the DB
|
||||
|
||||
## Type Generation
|
||||
|
||||
```typescript
|
||||
@@ -125,191 +54,21 @@ import { Tables } from '@kit/supabase/database';
|
||||
type Account = Tables<'accounts'>;
|
||||
```
|
||||
|
||||
Always prefer inferring types from generated Database types.
|
||||
Never modify `database.types.ts` - regenerate with `pnpm supabase:web:typegen`.
|
||||
|
||||
## 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
|
||||
## Authentication
|
||||
|
||||
```typescript
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const user = await requireUser(client, { verifyMfa: false });
|
||||
const user = await requireUser(client);
|
||||
const requiresMfa = await checkRequiresMultiFactorAuthentication(client);
|
||||
```
|
||||
|
||||
## Storage Security
|
||||
## Security Guidelines
|
||||
|
||||
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 operations
|
||||
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
|
||||
- Standard client: Trust RLS
|
||||
- Admin client: Validate everything manually
|
||||
- Always add indexes for foreign keys
|
||||
- Storage paths must include account_id
|
||||
|
||||
@@ -1,317 +1 @@
|
||||
# Database & Authentication Instructions
|
||||
|
||||
This file contains instructions for working with Supabase, database security, and authentication.
|
||||
|
||||
## Schemas and Migrations ⚠️
|
||||
|
||||
**Critical Understanding**: Schema files are NOT automatically applied to the database!
|
||||
|
||||
- **Schemas** (`supabase/schemas/`) represent the desired database state (source of truth)
|
||||
- **Migrations** (`supabase/migrations/`) are the actual SQL commands that modify the database
|
||||
|
||||
### The Required Workflow
|
||||
|
||||
1. **Edit schema file** (e.g., `supabase/schemas/18-projects.sql`)
|
||||
2. **Generate migration**:
|
||||
- Either copy content from schema to migration with `pnpm --filter web supabase migrations new my-feature` and `cp apps/web/supabase/schemas/18-my-new-feature.sql apps/web/supabase/migrations/$(ls -t apps/web/supabase/migrations/ | head -n1)`. Ideal for **new** tables, enums, etc.
|
||||
- or use `pnpm --filter web supabase:db:diff -f migration_name` (ideal when modifying existing entities)
|
||||
- This compares your schema against the current database and creates a migration
|
||||
3. **Apply migration**: `pnpm --filter web supabase migrations up`
|
||||
- This actually executes the SQL changes in the database
|
||||
|
||||
**⚠️ CRITICAL**: Editing a schema file alone does NOTHING to your database. You MUST generate and apply a migration for changes to take effect. Schema files are templates - migrations are the actual database operations.
|
||||
|
||||
## 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)
|
||||
);
|
||||
```
|
||||
|
||||
- **Never modify database.types.ts**: Instead, use the Supabase CLI using our package.json scripts to re-generate the types after resetting the DB
|
||||
|
||||
## 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 operations
|
||||
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
|
||||
@AGENTS.md
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,46 +1,43 @@
|
||||
# UI Components & Styling Instructions
|
||||
# UI Components & Styling
|
||||
|
||||
This file contains instructions for working with UI components, styling, and forms.
|
||||
## Skills
|
||||
|
||||
## Core UI Library
|
||||
For forms:
|
||||
- `/react-form-builder` - Forms with validation and server actions
|
||||
|
||||
Import from `packages/ui/src/`:
|
||||
## Import Convention
|
||||
|
||||
Always use `@kit/ui/{component}`:
|
||||
|
||||
```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/utils` for class merging
|
||||
|
||||
```tsx
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
function MyComponent({ className }) {
|
||||
return (
|
||||
<div className={cn('bg-background text-foreground', className)}>
|
||||
Content
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Rendering
|
||||
## Styling
|
||||
|
||||
Use the `If` component from `packages/ui/src/makerkit/if.tsx`:
|
||||
- Tailwind CSS v4 with semantic classes
|
||||
- Prefer: `bg-background`, `text-muted-foreground`, `border-border`
|
||||
- Use `cn()` for class merging
|
||||
- Never use hardcoded colors like `bg-white`
|
||||
|
||||
## Key Components
|
||||
|
||||
| Component | Usage |
|
||||
|-----------|-------|
|
||||
| `If` | Conditional rendering |
|
||||
| `Trans` | Internationalization |
|
||||
| `toast` | Notifications |
|
||||
| `Form*` | Form fields |
|
||||
| `Button` | Actions |
|
||||
| `Card` | Content containers |
|
||||
| `Alert` | Error/info messages |
|
||||
|
||||
## Conditional Rendering
|
||||
|
||||
```tsx
|
||||
import { If } from '@kit/ui/if';
|
||||
@@ -48,257 +45,27 @@ 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" />,
|
||||
}}
|
||||
/>
|
||||
<Trans i18nKey="namespace:key" values={{ name }} />
|
||||
```
|
||||
|
||||
## Toast Notifications
|
||||
## Testing Attributes
|
||||
|
||||
Use the `toast` utility from `@kit/ui/sonner`:
|
||||
Always add `data-test` for E2E:
|
||||
|
||||
```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!',
|
||||
});
|
||||
<button data-test="submit-button">Submit</button>
|
||||
```
|
||||
|
||||
## Common Component Patterns
|
||||
## Form Guidelines
|
||||
|
||||
### 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>
|
||||
```
|
||||
- Use `react-hook-form` with `zodResolver`
|
||||
- Never add generics to `useForm`
|
||||
- Use `useWatch` instead of `watch()`
|
||||
- Always include `FormMessage` for errors
|
||||
|
||||
@@ -1,289 +1 @@
|
||||
# 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/utils` for class merging
|
||||
|
||||
```tsx
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
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>
|
||||
```
|
||||
@AGENTS.md
|
||||
|
||||
@@ -31,6 +31,7 @@ export function MobileModeToggle(props: { className?: string }) {
|
||||
}
|
||||
|
||||
function setCookieTheme(theme: string) {
|
||||
const secure = typeof window !== 'undefined' && window.location.protocol === 'https:';
|
||||
const secure =
|
||||
typeof window !== 'undefined' && window.location.protocol === 'https:';
|
||||
document.cookie = `theme=${theme}; path=/; max-age=31536000; SameSite=Lax${secure ? '; Secure' : ''}`;
|
||||
}
|
||||
|
||||
@@ -126,7 +126,8 @@ export function SubMenuModeToggle() {
|
||||
}
|
||||
|
||||
function setCookieTheme(theme: string) {
|
||||
const secure = typeof window !== 'undefined' && window.location.protocol === 'https:';
|
||||
const secure =
|
||||
typeof window !== 'undefined' && window.location.protocol === 'https:';
|
||||
document.cookie = `theme=${theme}; path=/; max-age=31536000; SameSite=Lax${secure ? '; Secure' : ''}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,8 @@ const SidebarProvider: React.FC<
|
||||
_setOpen(value);
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
const secure = typeof window !== 'undefined' && window.location.protocol === 'https:';
|
||||
const secure =
|
||||
typeof window !== 'undefined' && window.location.protocol === 'https:';
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}; SameSite=Lax${secure ? '; Secure' : ''}`;
|
||||
},
|
||||
[setOpenProp, open],
|
||||
|
||||
Reference in New Issue
Block a user