diff --git a/.env.example b/.env.example index a1cab02..29d16ce 100644 --- a/.env.example +++ b/.env.example @@ -33,12 +33,18 @@ AI_CHAT_TOOLS=getCurrentDateTime,calculate AI_CHAT_MAX_STEPS=5 AI_CHAT_RATE_LIMIT_MAX_REQUESTS=20 AI_CHAT_RATE_LIMIT_WINDOW_MS=60000 +AI_CHAT_RATE_LIMIT_MAX_REQUESTS_FREE=20 +AI_CHAT_RATE_LIMIT_WINDOW_MS_FREE=60000 +AI_CHAT_RATE_LIMIT_MAX_REQUESTS_ORGANIZATIONS=60 +AI_CHAT_RATE_LIMIT_WINDOW_MS_ORGANIZATIONS=60000 # Lifetime per-account user message cap (boilerplate abuse protection) AI_CHAT_ENFORCE_LIFETIME_MESSAGE_LIMIT=true # Used when enforcement above is true. AI_CHAT_LIFETIME_MESSAGE_LIMIT=1 +AI_CHAT_LIFETIME_MESSAGE_LIMIT_FREE=1 +AI_CHAT_LIFETIME_MESSAGE_LIMIT_ORGANIZATIONS=500 # Convex chat history persistence (set AI_CHAT_HISTORY_ENABLED=false to disable) AI_CHAT_HISTORY_ENABLED=true @@ -47,7 +53,19 @@ AI_CHAT_HISTORY_MAX_MESSAGES_PER_THREAD=120 AI_CHAT_HISTORY_MAX_THREADS=50 AI_CHAT_HISTORY_THREAD_TITLE_MAX_LENGTH=80 AI_CHAT_HISTORY_QUERY_LIMIT=200 +AI_CHAT_HIST_MAX_MSGS_THREAD_FREE=120 +AI_CHAT_HIST_MAX_MSGS_THREAD_ORGS=240 +AI_CHAT_HIST_MAX_THREADS_FREE=50 +AI_CHAT_HIST_MAX_THREADS_ORGS=200 + +# File limits by organization plan +FILE_MAX_FILE_SIZE_BYTES_FREE=10485760 +FILE_MAX_FILE_SIZE_BYTES_ORGANIZATIONS=26214400 +FILE_MAX_FILES_PER_USER_FREE=100 +FILE_MAX_FILES_PER_USER_ORGANIZATIONS=500 # File upload image rate limiting (set either to 0 to disable) FILE_IMAGE_UPLOAD_RATE_LIMIT_MAX_UPLOADS=10 FILE_IMAGE_UPLOAD_RATE_LIMIT_WINDOW_MS=60000 +FILE_IMG_RL_MAX_UPLOADS_FREE=10 +FILE_IMG_RL_MAX_UPLOADS_ORGS=30 diff --git a/README.md b/README.md index 830fdd6..c4a7641 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,14 @@ -# Shipr +# Shipr (Multi-tenant Variant) -A production-ready tool for shipping SaaS fast. +This branch (`feat/multi-tenancy`) is a dedicated multi-tenant version of Shipr. + +- Tenant boundary: Clerk Organizations +- Backend data isolation: Convex organization-scoped records +- Billing scope: organization plan checks +- Access model: role/permission scaffold (`org:admin`, `org:member`) + +This branch is intended for SaaS products that require B2B multi-tenancy and is maintained separately from `master`. + +Important: this variant uses a fresh Convex baseline for tenant-scoped `files` and `chat` data. Do not deploy it over existing single-tenant datasets from `master` without clearing/backfilling those tables first. + +See `/docs/multi-tenancy.md` for architecture and implementation details. diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 9c43f0d..df92c6f 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -10,6 +10,7 @@ import type * as chat from "../chat.js"; import type * as files from "../files.js"; +import type * as lib_auth from "../lib/auth.js"; import type * as users from "../users.js"; import type { @@ -21,6 +22,7 @@ import type { declare const fullApi: ApiFromModules<{ chat: typeof chat; files: typeof files; + "lib/auth": typeof lib_auth; users: typeof users; }>; diff --git a/convex/chat.ts b/convex/chat.ts index 4d0d63e..230632f 100644 --- a/convex/chat.ts +++ b/convex/chat.ts @@ -2,32 +2,24 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; import type { MutationCtx, QueryCtx } from "./_generated/server"; -import { chatHistoryConfig } from "../src/lib/ai/chat-history-config"; +import { + chatHistoryConfig, + getChatHistoryLimitsForPlan, +} from "../src/lib/ai/chat-history-config"; +import { ORG_PERMISSIONS } from "../src/lib/auth/rbac"; +import { + requireCurrentUser, + resolveOrganizationBillingPlanForUser, + requireOrgPermission, + type OrganizationAuthContext, +} from "./lib/auth"; type ChatCtx = QueryCtx | MutationCtx; const DEFAULT_THREAD_TITLE = "New chat"; -async function requireCurrentUser(ctx: ChatCtx) { - const identity = await ctx.auth.getUserIdentity(); - if (!identity) { - throw new Error("Unauthorized: authentication required"); - } - - const user = await ctx.db - .query("users") - .withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject)) - .first(); - - if (!user) { - throw new Error("User not found"); - } - - return user; -} - -async function requireOwnedThread( +async function requireOrganizationThread( ctx: ChatCtx, - userId: Id<"users">, + auth: OrganizationAuthContext, threadId: Id<"chatThreads">, ) { const thread = await ctx.db.get(threadId); @@ -35,8 +27,8 @@ async function requireOwnedThread( throw new Error("Chat thread not found"); } - if (thread.userId !== userId) { - throw new Error("Forbidden: you do not own this chat thread"); + if (thread.orgId !== auth.orgId) { + throw new Error("Forbidden: thread belongs to another organization"); } return thread; @@ -54,38 +46,43 @@ function normalizeThreadTitle(raw: string): string { async function enforceThreadMessageBound( ctx: MutationCtx, threadId: Id<"chatThreads">, + maxMessagesPerThread: number, ) { while (true) { const messages = await ctx.db .query("chatMessages") .withIndex("by_thread_id", (q) => q.eq("threadId", threadId)) .order("asc") - .take(chatHistoryConfig.maxMessagesPerThread + 1); + .take(maxMessagesPerThread + 1); - if (messages.length <= chatHistoryConfig.maxMessagesPerThread) { + if (messages.length <= maxMessagesPerThread) { return; } - const overflow = messages.length - chatHistoryConfig.maxMessagesPerThread; + const overflow = messages.length - maxMessagesPerThread; await Promise.all( messages.slice(0, overflow).map((message) => ctx.db.delete(message._id)), ); } } -async function enforceThreadCountBound(ctx: MutationCtx, userId: Id<"users">) { +async function enforceThreadCountBound( + ctx: MutationCtx, + orgId: string, + maxThreadsPerWorkspace: number, +) { while (true) { const threads = await ctx.db .query("chatThreads") - .withIndex("by_user_id", (q) => q.eq("userId", userId)) + .withIndex("by_org_id_last_message", (q) => q.eq("orgId", orgId)) .order("asc") - .take(chatHistoryConfig.maxThreadsPerUser + 1); + .take(maxThreadsPerWorkspace + 1); - if (threads.length <= chatHistoryConfig.maxThreadsPerUser) { + if (threads.length <= maxThreadsPerWorkspace) { return; } - const overflow = threads.length - chatHistoryConfig.maxThreadsPerUser; + const overflow = threads.length - maxThreadsPerWorkspace; const threadsToDelete = threads.slice(0, overflow); for (const thread of threadsToDelete) { const threadMessages = await ctx.db @@ -98,23 +95,24 @@ async function enforceThreadCountBound(ctx: MutationCtx, userId: Id<"users">) { } } -export const listUserChatThreads = query({ +export const listOrganizationChatThreads = query({ args: {}, handler: async (ctx) => { if (!chatHistoryConfig.enabled) { return []; } - let user; + let auth; try { - user = await requireCurrentUser(ctx); + ({ auth } = await requireCurrentUser(ctx)); + requireOrgPermission(auth, ORG_PERMISSIONS.CHAT_READ); } catch { return []; } return await ctx.db .query("chatThreads") - .withIndex("by_user_id_last_message", (q) => q.eq("userId", user._id)) + .withIndex("by_org_id_last_message", (q) => q.eq("orgId", auth.orgId)) .order("desc") .take(chatHistoryConfig.queryLimit); }, @@ -129,15 +127,24 @@ export const createChatThread = mutation({ return null; } - const user = await requireCurrentUser(ctx); + const { auth, user } = await requireCurrentUser(ctx); + requireOrgPermission(auth, ORG_PERMISSIONS.CHAT_CREATE); + const orgPlan = resolveOrganizationBillingPlanForUser({ auth }); + const historyLimits = getChatHistoryLimitsForPlan(orgPlan); + const now = Date.now(); const threadId = await ctx.db.insert("chatThreads", { - userId: user._id, + orgId: auth.orgId, + createdByUserId: user._id, title: normalizeThreadTitle(args.title ?? DEFAULT_THREAD_TITLE), lastMessageAt: now, }); - await enforceThreadCountBound(ctx, user._id); + await enforceThreadCountBound( + ctx, + auth.orgId, + historyLimits.maxThreadsPerWorkspace, + ); return threadId; }, }); @@ -149,14 +156,15 @@ export const getThreadMessages = query({ return []; } - let user; + let auth; try { - user = await requireCurrentUser(ctx); + ({ auth } = await requireCurrentUser(ctx)); + requireOrgPermission(auth, ORG_PERMISSIONS.CHAT_READ); } catch { return []; } - await requireOwnedThread(ctx, user._id, args.threadId); + await requireOrganizationThread(ctx, auth, args.threadId); const messages = await ctx.db .query("chatMessages") @@ -179,8 +187,12 @@ export const saveUserChatMessage = mutation({ return null; } - const user = await requireCurrentUser(ctx); - const thread = await requireOwnedThread(ctx, user._id, args.threadId); + const { auth, user } = await requireCurrentUser(ctx); + requireOrgPermission(auth, ORG_PERMISSIONS.CHAT_CREATE); + const orgPlan = resolveOrganizationBillingPlanForUser({ auth }); + const historyLimits = getChatHistoryLimitsForPlan(orgPlan); + + const thread = await requireOrganizationThread(ctx, auth, args.threadId); const content = args.content.trim(); if (!content) { @@ -194,7 +206,8 @@ export const saveUserChatMessage = mutation({ } const insertedId = await ctx.db.insert("chatMessages", { - userId: user._id, + orgId: auth.orgId, + createdByUserId: user._id, threadId: args.threadId, role: args.role, content, @@ -206,7 +219,11 @@ export const saveUserChatMessage = mutation({ await ctx.db.patch(args.threadId, { title: normalizeThreadTitle(content) }); } - await enforceThreadMessageBound(ctx, args.threadId); + await enforceThreadMessageBound( + ctx, + args.threadId, + historyLimits.maxMessagesPerThread, + ); return insertedId; }, diff --git a/convex/files.ts b/convex/files.ts index 8fa3f47..e8883ec 100644 --- a/convex/files.ts +++ b/convex/files.ts @@ -4,65 +4,60 @@ import type { Id } from "./_generated/dataModel"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import { ALLOWED_FILE_MIME_TYPES, - FILE_UPLOAD_RATE_LIMITS, - FILE_STORAGE_LIMITS, + getFileImageUploadRateLimitForPlan, + getFileStorageLimitsForPlan, isImageMimeType, } from "../src/lib/files/config"; +import { ORG_PERMISSIONS } from "../src/lib/auth/rbac"; +import { + requireCurrentUser, + resolveOrganizationBillingPlanForUser, + requireOrgPermission, + type OrganizationAuthContext, +} from "./lib/auth"; const ALLOWED_MIME_TYPES = new Set(ALLOWED_FILE_MIME_TYPES); type FilesCtx = QueryCtx | MutationCtx; -async function requireCurrentUser(ctx: FilesCtx) { - const identity = await ctx.auth.getUserIdentity(); - if (!identity) { - throw new Error("Unauthorized: authentication required"); - } - - const user = await ctx.db - .query("users") - .withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject)) - .first(); - - if (!user) { - throw new Error("User not found"); - } - - return user; -} - -async function requireOwnedFile(ctx: FilesCtx, fileId: Id<"files">) { - const user = await requireCurrentUser(ctx); +async function requireOrganizationFile( + ctx: FilesCtx, + auth: OrganizationAuthContext, + fileId: Id<"files">, +) { const file = await ctx.db.get(fileId); if (!file) { throw new Error("File not found"); } - if (file.userId !== user._id) { - throw new Error("Forbidden: you do not own this file"); + if (file.orgId !== auth.orgId) { + throw new Error("Forbidden: file belongs to another organization"); } - return { user, file }; + return file; } async function enforceImageUploadRateLimit( ctx: MutationCtx, + orgId: string, userId: Id<"users">, + maxUploadsPerWindow: number, + windowMs: number, ) { - const { maxUploadsPerWindow, windowMs } = FILE_UPLOAD_RATE_LIMITS.image; - // Allow builders to disable by setting a non-positive limit/window in config. if (maxUploadsPerWindow <= 0 || windowMs <= 0) { return; } const windowStart = Date.now() - windowMs; - const userFiles = await ctx.db + const userFilesInOrg = await ctx.db .query("files") - .withIndex("by_user_id", (q) => q.eq("userId", userId)) + .withIndex("by_org_id_created_by_user_id", (q) => + q.eq("orgId", orgId).eq("createdByUserId", userId), + ) .collect(); - const recentImageUploads = userFiles.filter( + const recentImageUploads = userFilesInOrg.filter( (file) => isImageMimeType(file.mimeType) && file._creationTime >= windowStart, ); @@ -85,21 +80,26 @@ async function enforceImageUploadRateLimit( } // Generate a short-lived upload URL (Step 1 of upload flow) -// Requires authentication: only logged-in users can upload +// Requires authentication + active organization export const generateUploadUrl = mutation({ args: {}, handler: async (ctx): Promise => { - const user = await requireCurrentUser(ctx); + const { auth, user } = await requireCurrentUser(ctx); + requireOrgPermission(auth, ORG_PERMISSIONS.FILES_CREATE); + const orgPlan = resolveOrganizationBillingPlanForUser({ auth }); + const planLimits = getFileStorageLimitsForPlan(orgPlan); - // Check user file count limit - const existingFiles = await ctx.db + // Check uploader's file count limit in the active organization. + const existingFilesByUploader = await ctx.db .query("files") - .withIndex("by_user_id", (q) => q.eq("userId", user._id)) + .withIndex("by_org_id_created_by_user_id", (q) => + q.eq("orgId", auth.orgId).eq("createdByUserId", user._id), + ) .collect(); - if (existingFiles.length >= FILE_STORAGE_LIMITS.maxFilesPerUser) { + if (existingFilesByUploader.length >= planLimits.maxFilesPerUser) { throw new Error( - `File limit reached: maximum ${FILE_STORAGE_LIMITS.maxFilesPerUser} files per user`, + `File limit reached: maximum ${planLimits.maxFilesPerUser} files per uploader in this workspace for the ${orgPlan} plan`, ); } @@ -108,9 +108,9 @@ export const generateUploadUrl = mutation({ }); // Save file metadata after upload (Step 3 of upload flow) -// Validates file type, size, and ownership +// Validates file type, size, and organization scope. // SECURITY: Verifies actual file metadata from _storage system table -// to prevent client-side spoofing of size/MIME type +// to prevent client-side spoofing of size/MIME type. export const saveFile = mutation({ args: { storageId: v.id("_storage"), @@ -119,29 +119,32 @@ export const saveFile = mutation({ size: v.number(), }, handler: async (ctx, args) => { - const user = await requireCurrentUser(ctx); + const { auth, user } = await requireCurrentUser(ctx); + requireOrgPermission(auth, ORG_PERMISSIONS.FILES_CREATE); + const orgPlan = resolveOrganizationBillingPlanForUser({ auth }); + const planLimits = getFileStorageLimitsForPlan(orgPlan); + const imageRateLimit = getFileImageUploadRateLimitForPlan(orgPlan); // SECURITY: Cross-validate against actual stored file metadata - // This prevents client-side spoofing of file size and MIME type + // This prevents client-side spoofing of file size and MIME type. const storedFile = await ctx.db.system.get(args.storageId); if (!storedFile) { throw new Error("Storage ID not found: file may not have been uploaded"); } - // Use ACTUAL size from storage, not client-provided size + // Use ACTUAL size from storage, not client-provided size. const actualSize = storedFile.size; const actualMimeType = storedFile.contentType ?? args.mimeType; - // Validate actual file size (not the client-provided one) - if (actualSize > FILE_STORAGE_LIMITS.maxFileSizeBytes) { - // Clean up the uploaded file since we're rejecting it + // Validate actual file size (not the client-provided one). + if (actualSize > planLimits.maxFileSizeBytes) { await ctx.storage.delete(args.storageId); throw new Error( - `File too large: maximum size is ${FILE_STORAGE_LIMITS.maxFileSizeBytes / (1024 * 1024)}MB`, + `File too large: maximum size is ${planLimits.maxFileSizeBytes / (1024 * 1024)}MB for the ${orgPlan} plan`, ); } - // Validate actual MIME type (prefer storage contentType if available) + // Validate actual MIME type (prefer storage contentType if available). if (!ALLOWED_MIME_TYPES.has(actualMimeType)) { await ctx.storage.delete(args.storageId); throw new Error( @@ -152,23 +155,30 @@ export const saveFile = mutation({ // Rate limit image uploads to reduce abuse; delete blob on reject. if (isImageMimeType(actualMimeType)) { try { - await enforceImageUploadRateLimit(ctx, user._id); + await enforceImageUploadRateLimit( + ctx, + auth.orgId, + user._id, + imageRateLimit.maxUploadsPerWindow, + imageRateLimit.windowMs, + ); } catch (error) { await ctx.storage.delete(args.storageId); throw error; } } - // Sanitize file name: strip path separators and limit length + // Sanitize file name: strip path separators and limit length. const sanitizedName = args.fileName .replace(/[/\\]/g, "_") - .replace(/[<>:"|?*]/g, "_") + .replace(/[<>:\"|?*]/g, "_") .slice(0, 255); - // Store using ACTUAL verified metadata, not client-provided values + // Store using ACTUAL verified metadata, not client-provided values. return await ctx.db.insert("files", { + orgId: auth.orgId, storageId: args.storageId, - userId: user._id, + createdByUserId: user._id, fileName: sanitizedName, mimeType: actualMimeType, size: actualSize, @@ -176,52 +186,67 @@ export const saveFile = mutation({ }, }); -// List files for the currently authenticated user -export const getUserFiles = query({ +// List files for the active organization. +export const getOrganizationFiles = query({ args: {}, handler: async (ctx) => { - let user; + let auth; try { - user = await requireCurrentUser(ctx); + ({ auth } = await requireCurrentUser(ctx)); + requireOrgPermission(auth, ORG_PERMISSIONS.FILES_READ); } catch { return []; } const files = await ctx.db .query("files") - .withIndex("by_user_id", (q) => q.eq("userId", user._id)) + .withIndex("by_org_id", (q) => q.eq("orgId", auth.orgId)) .order("desc") .collect(); - // Attach serving URLs to each file - return Promise.all( - files.map(async (file) => ({ - ...file, - url: await ctx.storage.getUrl(file.storageId), - })), + return await Promise.all( + files.map(async (file) => { + const uploader = await ctx.db.get(file.createdByUserId); + return { + ...file, + url: await ctx.storage.getUrl(file.storageId), + uploader: uploader + ? { + name: uploader.name ?? null, + email: uploader.email, + imageUrl: uploader.imageUrl ?? null, + } + : null, + }; + }), ); }, }); -// Get a single file's URL (with ownership check) +// Get a single file's URL (with organization + permission checks). export const getFileUrl = query({ args: { fileId: v.id("files") }, handler: async (ctx, args) => { - const { file } = await requireOwnedFile(ctx, args.fileId); + const { auth } = await requireCurrentUser(ctx); + requireOrgPermission(auth, ORG_PERMISSIONS.FILES_READ); + const file = await requireOrganizationFile(ctx, auth, args.fileId); const url = await ctx.storage.getUrl(file.storageId); return { ...file, url }; }, }); -// Delete a file (with ownership check) -// Removes both the storage blob and the database record +// Delete a file (admin-only by default fallback role matrix). +// Removes both the storage blob and the database record. export const deleteFile = mutation({ args: { fileId: v.id("files") }, handler: async (ctx, args) => { - const { file } = await requireOwnedFile(ctx, args.fileId); + const { auth } = await requireCurrentUser(ctx); + requireOrgPermission(auth, ORG_PERMISSIONS.FILES_DELETE); + + const file = await requireOrganizationFile(ctx, auth, args.fileId); - // Delete the blob from storage first, then the metadata + // Delete the blob from storage first, then the metadata. await ctx.storage.delete(file.storageId); await ctx.db.delete(args.fileId); }, diff --git a/convex/lib/auth.ts b/convex/lib/auth.ts new file mode 100644 index 0000000..79c175d --- /dev/null +++ b/convex/lib/auth.ts @@ -0,0 +1,223 @@ +import type { Doc } from "../_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "../_generated/server"; +import { + hasOrgPermission, + normalizeOrganizationBillingPlan, + type OrganizationBillingPlan, + type OrgPermission, +} from "../../src/lib/auth/rbac"; + +type ConvexCtx = QueryCtx | MutationCtx; + +type AuthIdentity = NonNullable< + Awaited> +>; + +export interface OrganizationAuthContext { + identity: AuthIdentity; + clerkId: string; + orgId: string; + orgRole: string; + orgPermissions: string[]; + orgPlan: OrganizationBillingPlan; +} + +function getStringClaim(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function getStringArrayClaim(value: unknown): string[] { + if (typeof value === "string" && value.trim().length > 0) { + return value + .split(/[,\s]+/) + .map((item) => item.trim()) + .filter((item) => item.length > 0); + } + + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter((item) => item.length > 0); +} + +function getObjectClaim(value: unknown): Record | null { + return value !== null && typeof value === "object" + ? (value as Record) + : null; +} + +function getClaimFromSources( + identityClaims: Record, + parser: (value: unknown) => T, + isValid: (value: T) => boolean, + keys: string[], +): T | null { + const nestedObjects = Object.values(identityClaims) + .map((value) => getObjectClaim(value)) + .filter((value): value is Record => value !== null); + + const sources = [identityClaims, ...nestedObjects]; + + for (const source of sources) { + for (const key of keys) { + const parsed = parser(source[key]); + if (isValid(parsed)) { + return parsed; + } + } + } + + return null; +} + +function collectStringArrayClaims( + identityClaims: Record, + keys: string[], +): string[] { + const nestedObjects = Object.values(identityClaims) + .map((value) => getObjectClaim(value)) + .filter((value): value is Record => value !== null); + const sources = [identityClaims, ...nestedObjects]; + + const values = new Set(); + for (const source of sources) { + for (const key of keys) { + for (const claim of getStringArrayClaim(source[key])) { + values.add(claim); + } + } + } + + return [...values]; +} + +export async function requireAuthenticatedIdentity( + ctx: ConvexCtx, +): Promise { + const identity = await ctx.auth.getUserIdentity(); + + if (!identity) { + throw new Error("Unauthorized: authentication required"); + } + + return identity; +} + +export async function requireOrganizationContext( + ctx: ConvexCtx, +): Promise { + const identity = await requireAuthenticatedIdentity(ctx); + const identityClaims = identity as Record; + + const orgId = getClaimFromSources( + identityClaims, + getStringClaim, + (value): value is string => Boolean(value), + [ + "org_id", + "orgId", + "organization_id", + "organizationId", + "https://clerk.dev/org_id", + "https://clerk.dev/organization_id", + ], + ); + const orgRole = getClaimFromSources( + identityClaims, + getStringClaim, + (value): value is string => Boolean(value), + [ + "org_role", + "orgRole", + "organization_role", + "organizationRole", + "https://clerk.dev/org_role", + "https://clerk.dev/organization_role", + ], + ); + const orgPermissions = collectStringArrayClaims(identityClaims, [ + "org_permissions", + "orgPermissions", + "organization_permissions", + "organizationPermissions", + "https://clerk.dev/org_permissions", + "https://clerk.dev/organization_permissions", + ]); + const orgPlanClaim = getClaimFromSources( + identityClaims, + getStringClaim, + (value): value is string => Boolean(value), + [ + "org_plan", + "orgPlan", + "organization_plan", + "organizationPlan", + "https://clerk.dev/org_plan", + "https://clerk.dev/organization_plan", + "plan", + ], + ); + + if (!orgId) { + throw new Error( + "Forbidden: active organization required (missing org claim in Convex token)", + ); + } + + if (!orgRole) { + throw new Error("Forbidden: organization role is missing"); + } + + return { + identity, + clerkId: identity.subject, + orgId, + orgRole, + orgPermissions, + orgPlan: normalizeOrganizationBillingPlan(orgPlanClaim), + }; +} + +export function requireOrgPermission( + authContext: OrganizationAuthContext, + permission: OrgPermission, +): void { + const allowed = hasOrgPermission({ + orgRole: authContext.orgRole, + orgPermissions: authContext.orgPermissions, + permission, + }); + + if (!allowed) { + throw new Error(`Forbidden: missing permission ${permission}`); + } +} + +export async function requireCurrentUser( + ctx: ConvexCtx, +): Promise<{ auth: OrganizationAuthContext; user: Doc<"users"> }> { + const auth = await requireOrganizationContext(ctx); + + const user = await ctx.db + .query("users") + .withIndex("by_clerk_id", (q) => q.eq("clerkId", auth.clerkId)) + .first(); + + if (!user) { + throw new Error("User not found"); + } + + return { auth, user }; +} + +export function resolveOrganizationBillingPlanForUser(params: { + auth: OrganizationAuthContext; +}): OrganizationBillingPlan { + const { auth } = params; + return auth.orgPlan; +} diff --git a/convex/schema.ts b/convex/schema.ts index 75d745b..90c45d2 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -2,12 +2,16 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ + // NOTE: Multi-tenant branch baseline. + // This schema is intentionally not backward compatible with legacy single-tenant + // files/chat rows from master. Use a fresh Convex deployment or clear/backfill + // legacy rows before rollout. users: defineTable({ clerkId: v.string(), email: v.string(), name: v.optional(v.string()), imageUrl: v.optional(v.string()), - plan: v.optional(v.string()), // "free" | "pro": synced from Clerk Billing via useSyncUser + plan: v.optional(v.string()), // "free" | "organizations": synced from Clerk Billing via useSyncUser onboardingCompleted: v.optional(v.boolean()), // Whether user completed onboarding onboardingStep: v.optional(v.string()), // Current onboarding step: "welcome" | "profile" | "preferences" | "complete" // NOTE: Do not add a manual createdAt field. @@ -15,31 +19,35 @@ export default defineSchema({ }).index("by_clerk_id", ["clerkId"]), files: defineTable({ + orgId: v.string(), // Clerk organization ID (tenant boundary) storageId: v.id("_storage"), - userId: v.id("users"), // Reference to the owning user document + createdByUserId: v.id("users"), // User that uploaded the file fileName: v.string(), // Original filename (sanitized) mimeType: v.string(), // MIME type (e.g. "image/png", "application/pdf") size: v.number(), // File size in bytes // NOTE: Do not add a manual createdAt field. // Convex automatically provides _creationTime on every document. }) - .index("by_user_id", ["userId"]) + .index("by_org_id", ["orgId"]) + .index("by_org_id_created_by_user_id", ["orgId", "createdByUserId"]) .index("by_storage_id", ["storageId"]), chatThreads: defineTable({ - userId: v.id("users"), + orgId: v.string(), // Clerk organization ID (tenant boundary) + createdByUserId: v.id("users"), title: v.string(), lastMessageAt: v.number(), }) - .index("by_user_id", ["userId"]) - .index("by_user_id_last_message", ["userId", "lastMessageAt"]), + .index("by_org_id", ["orgId"]) + .index("by_org_id_last_message", ["orgId", "lastMessageAt"]), chatMessages: defineTable({ - userId: v.id("users"), + orgId: v.string(), // Clerk organization ID (tenant boundary) threadId: v.id("chatThreads"), + createdByUserId: v.id("users"), role: v.union(v.literal("user"), v.literal("assistant")), content: v.string(), }) - .index("by_user_id", ["userId"]) + .index("by_org_id", ["orgId"]) .index("by_thread_id", ["threadId"]), }); diff --git a/convex/users.ts b/convex/users.ts index 157ce50..a71d474 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1,29 +1,20 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; // Onboarding steps export type OnboardingStep = "welcome" | "profile" | "preferences" | "complete"; -// Get user by Clerk ID -export const getUserByClerkId = query({ - args: { clerkId: v.string() }, - handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); - if (!identity) { - return null; - } +async function requireAuthenticatedClerkId( + ctx: QueryCtx | MutationCtx, +): Promise { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Unauthorized: authentication required"); + } - // Users can only query their own record - if (identity.subject !== args.clerkId) { - throw new Error("Forbidden: cannot access another user's data"); - } - - return await ctx.db - .query("users") - .withIndex("by_clerk_id", (q) => q.eq("clerkId", args.clerkId)) - .first(); - }, -}); + return identity.subject; +} // Get the currently authenticated user export const getCurrentUser = query({ @@ -42,29 +33,20 @@ export const getCurrentUser = query({ }); // Create or update user (called from client-side useSyncUser hook) -// Requires authentication: user can only sync their own data +// Requires authentication and derives clerkId from the authenticated identity. export const createOrUpdateUser = mutation({ args: { - clerkId: v.string(), email: v.string(), name: v.optional(v.string()), imageUrl: v.optional(v.string()), plan: v.optional(v.string()), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); - if (!identity) { - throw new Error("Unauthorized: authentication required"); - } - - // Ensure users can only create/update their own record - if (identity.subject !== args.clerkId) { - throw new Error("Forbidden: cannot modify another user's data"); - } + const clerkId = await requireAuthenticatedClerkId(ctx); const existing = await ctx.db .query("users") - .withIndex("by_clerk_id", (q) => q.eq("clerkId", args.clerkId)) + .withIndex("by_clerk_id", (q) => q.eq("clerkId", clerkId)) .first(); if (existing) { @@ -78,7 +60,7 @@ export const createOrUpdateUser = mutation({ } return await ctx.db.insert("users", { - clerkId: args.clerkId, + clerkId, email: args.email, name: args.name, imageUrl: args.imageUrl, @@ -87,24 +69,15 @@ export const createOrUpdateUser = mutation({ }, }); -// Delete user by Clerk ID -// Requires authentication: user can only delete their own record -export const deleteUser = mutation({ - args: { clerkId: v.string() }, - handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); - if (!identity) { - throw new Error("Unauthorized: authentication required"); - } - - // Ensure users can only delete their own record - if (identity.subject !== args.clerkId) { - throw new Error("Forbidden: cannot delete another user's data"); - } +// Delete current user +export const deleteCurrentUser = mutation({ + args: {}, + handler: async (ctx) => { + const clerkId = await requireAuthenticatedClerkId(ctx); const user = await ctx.db .query("users") - .withIndex("by_clerk_id", (q) => q.eq("clerkId", args.clerkId)) + .withIndex("by_clerk_id", (q) => q.eq("clerkId", clerkId)) .first(); if (user) { @@ -141,18 +114,14 @@ export const updateOnboardingStep = mutation({ v.literal("welcome"), v.literal("profile"), v.literal("preferences"), - v.literal("complete"), ), }, handler: async (ctx, args) => { - const identity = await ctx.auth.getUserIdentity(); - if (!identity) { - throw new Error("Unauthorized: authentication required"); - } + const clerkId = await requireAuthenticatedClerkId(ctx); const user = await ctx.db .query("users") - .withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject)) + .withIndex("by_clerk_id", (q) => q.eq("clerkId", clerkId)) .first(); if (!user) { @@ -169,14 +138,11 @@ export const updateOnboardingStep = mutation({ export const completeOnboarding = mutation({ args: {}, handler: async (ctx) => { - const identity = await ctx.auth.getUserIdentity(); - if (!identity) { - throw new Error("Unauthorized: authentication required"); - } + const clerkId = await requireAuthenticatedClerkId(ctx); const user = await ctx.db .query("users") - .withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject)) + .withIndex("by_clerk_id", (q) => q.eq("clerkId", clerkId)) .first(); if (!user) { @@ -194,14 +160,11 @@ export const completeOnboarding = mutation({ export const resetOnboarding = mutation({ args: {}, handler: async (ctx) => { - const identity = await ctx.auth.getUserIdentity(); - if (!identity) { - throw new Error("Unauthorized: authentication required"); - } + const clerkId = await requireAuthenticatedClerkId(ctx); const user = await ctx.db .query("users") - .withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject)) + .withIndex("by_clerk_id", (q) => q.eq("clerkId", clerkId)) .first(); if (!user) { diff --git a/docs/architecture.md b/docs/architecture.md index 9bbcad2..13d4b1c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,7 +5,7 @@ | Layer | Tool | | -------------- | -------------------------- | | Framework | Next.js 16 (App Router) | -| Auth | Clerk | +| Auth | Clerk (Organizations) | | Database | Convex | | Styling | Tailwind CSS 4 | | Analytics | PostHog + Vercel Analytics | @@ -13,125 +13,88 @@ | Payments | Clerk Billing | | Email | Resend | -## Route Groups - -``` -src/app/ -├── (auth)/ # Sign-in, sign-up pages (Clerk) -├── (dashboard)/ # Protected pages (requires auth) -├── (legal)/ # Privacy, terms, cookies -├── (marketing)/ # Landing, features, pricing, about, docs, blog -│ └── blog/ # Blog index + [slug] detail pages -├── api/ -│ ├── email/ # Send transactional emails via Resend -│ └── health/ # Health check endpoint (rate-limited) -├── layout.tsx # Root layout - providers, fonts, metadata -├── not-found.tsx # Custom 404 page -├── error.tsx # App-level error boundary -└── global-error.tsx # Root error boundary (catches layout errors) -``` - -### (marketing) +## Multi-tenant Model -Public pages with `HeroHeader` and `Footer`. No auth required. Includes the blog at `/blog` with individual post pages at `/blog/[slug]`. +This branch is organization-first. -### (auth) +- Tenant boundary: Clerk active organization (`orgId`) +- Workspace data: organization-scoped Convex records +- Personal workspace path: hidden in organization picker +- Billing scope: active organization plan -Clerk's sign-in/sign-up catch-all routes. Centered layout. - -### (dashboard) - -Protected area. Uses sidebar layout with `useSyncUser` to keep Convex in sync with Clerk. - -### (legal) - -Static legal pages sharing a minimal layout. - -## Provider Stack - -Providers wrap the app in this order (see `layout.tsx`): +## Route Groups ``` -ThemeProvider - next-themes (light/dark/system) - PostHogProvider - Analytics client - ClerkProvider - Auth (adapts to theme) - PostHogIdentify - Links Clerk user to PostHog - PostHogPageview - Tracks route changes - TooltipProvider - UI tooltips - ConvexProvider - Realtime database (uses Clerk auth) +src/app/ +├── (auth)/ +├── (dashboard)/ +│ ├── dashboard/ +│ │ ├── page.tsx +│ │ ├── files/page.tsx +│ │ ├── chat/page.tsx +│ └── onboarding/page.tsx +├── (legal)/ +├── (marketing)/ +└── api/ ``` -## Data Flow +## Middleware + Guarding -### User Sync +`src/proxy.ts` enforces: -``` -Clerk (auth source of truth) - > useSyncUser hook (client-side) - > Convex createOrUpdateUser mutation - > Convex users table -``` +- auth protection on `/dashboard(.*)` and `/onboarding(.*)` +- redirect to `/onboarding` when signed in without active org -The `useSyncUser` hook runs on auth'd pages. It compares the Clerk user with the Convex record and only writes when data has changed (email, name, avatar, plan). +## Provider Stack -### Plan Detection +Providers in `src/app/layout.tsx`: ``` -Clerk Billing (has plan: "pro") - > useUserPlan hook - > returns { plan, isPro, isFree, isLoading } +ThemeProvider + PostHogProvider + ClerkProvider + PostHogIdentify + PostHogPageview + TooltipProvider + ConvexProviderWithClerk ``` -No separate billing table - plan is derived from Clerk's `has()` check and synced to Convex for server-side access. +## Data Model -## API Routes +### Account-level -- `POST /api/email` - sends a transactional email to the authenticated user via Resend. Protected by Clerk auth and rate-limited to 10 req/min per IP. Accepts a JSON body with a `template` field (`"welcome"` or `"plan-changed"`) and the template's required data. -- `GET /api/health` - returns `{ status, timestamp, uptime }`. Rate-limited to 30 req/min per IP via the in-memory sliding window limiter in `src/lib/rate-limit.ts`. +`users` table remains per user (`clerkId` keyed): -## Blog +- profile +- onboarding state +- synced plan snapshot -Posts are defined as a simple array in `src/lib/blog.ts`. No MDX or CMS - just add an object to `BLOG_POSTS` and the blog index + detail page + sitemap + JSON-LD are generated automatically. +### Organization-level -## Email (Resend) +`files`, `chatThreads`, and `chatMessages` are organization-scoped via `orgId`. -Transactional emails are sent via [Resend](https://resend.com). Everything lives in `src/lib/emails/`: - -- `send.ts` - `sendEmail()` helper that wraps the Resend SDK (lazily initialized) -- `welcome.ts` - `welcomeEmail({ name })` returns `{ subject, html }` -- `plan-changed.ts` - `planChangedEmail({ name, previousPlan, newPlan })` returns `{ subject, html }` -- `index.ts` - barrel exports for all templates and the send helper - -Use `sendEmail()` in any server context (API routes, server actions): - -```ts -import { sendEmail, welcomeEmail } from "@/lib/emails"; - -const { subject, html } = welcomeEmail({ name: "Ege" }); -await sendEmail({ to: "ege@example.com", subject, html }); -``` +## Access Control -Requires `RESEND_API_KEY` in `.env`. Optionally set `RESEND_FROM_EMAIL` to override the default sender address. +- Shared RBAC constants: `src/lib/auth/rbac.ts` +- Convex auth guards: `convex/lib/auth.ts` +- API routes enforce active org context +- Convex functions fail closed on missing auth/org/permissions -## Rate Limiting +## Billing Flow -`src/lib/rate-limit.ts` provides a sliding window rate limiter for API routes. In-memory - suitable for single-instance or low-traffic Vercel serverless. Swap with Upstash Redis for multi-instance production. +- Pricing table uses organization billing mode. +- Plan checks use active-org Clerk context. ## Key Files -| File | Purpose | -| ------------------------------------ | ----------------------------------- | -| `src/lib/constants.ts` | SEO config, routes, structured data | -| `src/lib/structured-data.tsx` | JSON-LD components for SEO | -| `src/lib/blog.ts` | Blog post data & helpers | -| `src/lib/rate-limit.ts` | In-memory rate limiter | -| `src/lib/emails/send.ts` | Resend sendEmail helper | -| `src/lib/emails/` | Email templates (welcome, plan) | -| `src/app/api/email/route.ts` | Send email API route | -| `src/lib/sentry.ts` | Error tracking helpers | -| `src/lib/convex-client-provider.tsx` | Convex + Clerk integration | -| `src/hooks/use-sync-user.ts` | Clerk to Convex user sync | -| `src/hooks/use-user-plan.ts` | Plan gating hook | -| `src/hooks/use-mobile.ts` | Responsive breakpoint detection | -| `convex/schema.ts` | Database schema | -| `convex/users.ts` | User CRUD mutations/queries | +| File | Purpose | +| ------------------------------------- | -------------------------------------------- | +| `src/lib/auth/rbac.ts` | Shared role/permission constants + helpers | +| `convex/lib/auth.ts` | Convex auth/org/permission guard helpers | +| `convex/schema.ts` | Org-scoped data schema | +| `convex/files.ts` | Workspace file access + validation | +| `convex/chat.ts` | Workspace chat access + persistence | +| `convex/users.ts` | Account-level user sync + onboarding | +| `src/proxy.ts` | Auth + org-required route guarding | +| `src/components/app-sidebar.tsx` | Sidebar navigation + OrganizationSwitcher | +| `src/app/(dashboard)/onboarding/page.tsx` | Organization selection/creation entry screen | diff --git a/docs/authentication.md b/docs/authentication.md index 61a8271..54ba115 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -1,28 +1,23 @@ # Authentication -Shipr uses [Clerk](https://clerk.com) for authentication and [Convex](https://convex.dev) for the backend database. +Shipr multi-tenant variant uses [Clerk](https://clerk.com) for authentication + organizations and [Convex](https://convex.dev) for backend data. ## How It Works -1. **Clerk** handles sign-in, sign-up, session management, and billing plans -2. **Convex** stores user data synced from Clerk via the `useSyncUser` hook -3. Users are identified by their Clerk ID across both systems +1. Clerk handles sign-in/sign-up/session state. +2. Clerk active organization (`orgId`) defines tenant context. +3. Convex validates auth, org context, and permissions on each protected function. +4. `useSyncUser` syncs account-level user profile data into Convex `users`. -## Architecture +## Organization-Required Flow -``` -User signs in via Clerk - | -ClerkProviderWrapper (adapts to dark/light theme) - | -ConvexProviderWithClerk (passes Clerk auth to Convex) - | -useSyncUser hook (syncs Clerk user data to Convex DB) -``` +- Authenticated users without an active organization are redirected to `/onboarding`. +- Personal workspace mode is hidden in Clerk organization components. +- Dashboard features are available only after selecting/creating an organization. ## Providers -Providers are nested in the root layout (`src/app/layout.tsx`): +Providers are nested in `src/app/layout.tsx`: ```tsx @@ -38,33 +33,45 @@ Providers are nested in the root layout (`src/app/layout.tsx`): `src/components/clerk-provider-wrapper.tsx` -Wraps `ClerkProvider` and automatically applies the dark theme based on `next-themes`. +Wraps `ClerkProvider` and applies theme-aware Clerk styling. ### ConvexClientProvider `src/lib/convex-client-provider.tsx` -Creates a singleton `ConvexReactClient` and bridges Clerk auth into Convex via `ConvexProviderWithClerk`. +Bridges Clerk auth to Convex with `ConvexProviderWithAuth` and a custom Clerk token fetcher that: + +- requests the `convex` JWT template +- passes `organizationId` from the active Clerk org +- refreshes token cache when org context changes + +This keeps Convex org claims aligned with Clerk's active organization. ## User Sync -The `useSyncUser` hook (`src/hooks/use-sync-user.ts`) runs on authenticated pages and: +`src/hooks/use-sync-user.ts` -- Reads the current Clerk user + billing plan -- Checks if the Convex user record exists and is up-to-date -- Creates or patches the Convex record only when data has changed +- Reads current Clerk user and org-scoped billing plan +- Upserts Convex `users` record only when values changed +- `clerkId` is derived server-side in Convex (not client-supplied) ## Convex Schema -Users table (`convex/schema.ts`): +### Users (account-level) -| Field | Type | Description | -| ---------- | --------- | ----------------------- | -| `clerkId` | `string` | Clerk user ID (indexed) | -| `email` | `string` | Primary email | -| `name` | `string?` | Full name | -| `imageUrl` | `string?` | Profile image URL | -| `plan` | `string?` | `"free"` or `"pro"` | +- `clerkId` +- `email` +- `name` +- `imageUrl` +- `plan` +- `onboardingCompleted` +- `onboardingStep` + +### Workspace-scoped tables + +- `files.orgId` +- `chatThreads.orgId` +- `chatMessages.orgId` ## Environment Variables @@ -75,12 +82,19 @@ Users table (`convex/schema.ts`): | `NEXT_PUBLIC_CONVEX_URL` | Convex deployment URL | | `CLERK_JWT_ISSUER_DOMAIN` | Clerk JWT issuer for Convex | -## Route Groups +## Required Clerk JWT Template Claims + +The Clerk JWT template named `convex` must include org claims used by Convex guards: + +- `org_id`: `{{org.id}}` +- `org_role`: `{{org.role}}` +- `org_permissions`: `{{org.permissions}}` -- `(auth)` - Sign-in and sign-up pages using Clerk's prebuilt components -- `(dashboard)` - Protected pages that require authentication -- `(marketing)` - Public pages (no auth required) +If these claims are missing, Convex will reject org-scoped functions. ## Security -All Convex mutations and queries enforce ownership checks - users can only read, update, or delete their own records. The `identity.subject` from Clerk JWT is compared against the `clerkId` argument on every operation. +- Convex functions require authenticated identity. +- Workspace functions require active organization context. +- Workspace actions require role/permission checks. +- Cross-tenant reads/writes are blocked by explicit `orgId` checks. diff --git a/docs/deployment.md b/docs/deployment.md index 460e23b..ad2c5d4 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -2,88 +2,59 @@ ## Environment Variables -Copy `.env.example` to `.env` and fill in the values: +Copy `.env.example` and fill values: ```sh cp .env.example .env ``` -| Variable | Description | Required | -| ---------------------------------------- | -------------------------------------------------- | -------- | -| `NEXT_PUBLIC_SITE_URL` | Your production URL (e.g. `https://shipr.dev`) | Yes | -| `NEXT_PUBLIC_CONVEX_URL` | Convex deployment URL | Yes | -| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Clerk publishable key | Yes | -| `CLERK_SECRET_KEY` | Clerk secret key | Yes | -| `CLERK_JWT_ISSUER_DOMAIN` | Clerk JWT issuer for Convex auth | Yes | -| `AI_GATEWAY_API_KEY` | Vercel AI Gateway key for dashboard chat | Yes | -| `AI_CHAT_MODEL` | Chat model ID (defaults to `openai/gpt-4.1-mini`) | Optional | -| `AI_CHAT_SYSTEM_PROMPT` | System prompt for chat assistant behavior | Optional | -| `AI_CHAT_TOOLS` | Enabled chat tool names, comma-separated | Optional | -| `AI_CHAT_MAX_STEPS` | Max model steps/tool calls per response | Optional | -| `AI_CHAT_RATE_LIMIT_MAX_REQUESTS` | Max chat requests per rate-limit window | Optional | -| `AI_CHAT_RATE_LIMIT_WINDOW_MS` | Chat rate-limit window in milliseconds | Optional | -| `AI_CHAT_ENFORCE_LIFETIME_MESSAGE_LIMIT` | Enable/disable lifetime per-user message cap | Optional | -| `AI_CHAT_LIFETIME_MESSAGE_LIMIT` | Lifetime message cap when enabled | Optional | -| `AI_CHAT_HISTORY_ENABLED` | Enable/disable Convex chat history persistence | Optional | -| `AI_CHAT_HISTORY_MAX_MESSAGE_LENGTH` | Max chars per persisted chat message | Optional | -| `AI_CHAT_HISTORY_MAX_MESSAGES_PER_THREAD` | Max persisted chat messages per chat thread | Optional | -| `AI_CHAT_HISTORY_MAX_THREADS` | Max chat threads per user | Optional | -| `AI_CHAT_HISTORY_THREAD_TITLE_MAX_LENGTH` | Max chars for auto-generated chat titles | Optional | -| `AI_CHAT_HISTORY_QUERY_LIMIT` | Max persisted messages returned to chat UI | Optional | -| `FILE_IMAGE_UPLOAD_RATE_LIMIT_MAX_UPLOADS` | Max image uploads per user in each window | Optional | -| `FILE_IMAGE_UPLOAD_RATE_LIMIT_WINDOW_MS` | Image upload rate-limit window in milliseconds | Optional | -| `RESEND_API_KEY` | Resend API key for transactional emails | Yes | -| `RESEND_FROM_EMAIL` | Sender address (defaults to onboarding@resend.dev) | Optional | -| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog project API key | Optional | -| `NEXT_PUBLIC_POSTHOG_HOST` | PostHog ingest host | Optional | -| `SENTRY_AUTH_TOKEN` | Sentry auth token for source maps | Optional | +Key required variables: -## Vercel - -1. Push your repo to GitHub -2. Import the project in [Vercel](https://vercel.com/new) -3. Add all environment variables in the Vercel dashboard -4. Deploy - Vercel auto-detects Next.js +| Variable | Description | +| ----------------------------------- | -------------------------------------------------- | +| `NEXT_PUBLIC_SITE_URL` | Production URL | +| `NEXT_PUBLIC_CONVEX_URL` | Convex deployment URL | +| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Clerk publishable key | +| `CLERK_SECRET_KEY` | Clerk secret key | +| `CLERK_JWT_ISSUER_DOMAIN` | Clerk JWT issuer for Convex | +| `AI_GATEWAY_API_KEY` | Vercel AI Gateway key for dashboard chat | +| `RESEND_API_KEY` | Resend API key for transactional email | -### Build settings +## Multi-tenant Requirements -Vercel should auto-detect these, but if not: +This branch requires Clerk Organizations. -- **Framework:** Next.js -- **Build command:** `pnpm build` -- **Output directory:** `.next` - -## Convex +Before deploying: -1. Install the CLI: `pnpm add -g convex` -2. Run `npx convex dev` locally to sync your schema -3. For production, deploy with `npx convex deploy` -4. Set `CLERK_JWT_ISSUER_DOMAIN` in the [Convex dashboard](https://dashboard.convex.dev) under Authentication +1. Enable Organizations in Clerk. +2. Ensure users can create/join organizations. +3. Configure organization roles (`org:admin`, `org:member`). +4. (Optional) Configure custom permissions matching `src/lib/auth/rbac.ts`. +5. If using paid plans, configure Clerk Billing for organizations. +6. Configure Clerk JWT template `convex` with `org_id`, `org_role`, and `org_permissions` claims. -## Clerk +## Vercel -1. Create a project at [clerk.com](https://clerk.com) -2. Copy your keys to `.env` -3. Enable Clerk Billing if you want Pro/Free plan gating -4. Configure the JWT issuer domain for Convex integration +1. Push branch to GitHub. +2. Import in [Vercel](https://vercel.com/new). +3. Add environment variables. +4. Deploy with `pnpm build`. -## Sentry +## Convex -1. Create a project at [sentry.io](https://sentry.io) -2. Add `SENTRY_AUTH_TOKEN` to your environment -3. Source maps are uploaded automatically during build via `@sentry/nextjs` -4. Error tracking works out of the box - see `src/lib/sentry.ts` for helpers +1. Run `npx convex dev` locally while developing. +2. Deploy with `npx convex deploy`. +3. Ensure `CLERK_JWT_ISSUER_DOMAIN` is configured in Convex auth settings. -## Resend +## Clerk -1. Create an account at [resend.com](https://resend.com) -2. Add a verified domain (or use the sandbox sender for testing) -3. Copy your API key and add `RESEND_API_KEY` to `.env` -4. Optionally set `RESEND_FROM_EMAIL` to your verified sender address (e.g. `hello@yourdomain.com`) -5. The `POST /api/email` route and the `sendEmail` helper in `src/lib/emails/send.ts` will use these values at runtime +1. Create/select your Clerk project. +2. Configure sign-in/sign-up URLs. +3. Enable Organizations. +4. Verify active-org claims are available in sessions. -## PostHog +## Security Checklist -1. Create a project at [posthog.com](https://posthog.com) -2. Add `NEXT_PUBLIC_POSTHOG_KEY` and `NEXT_PUBLIC_POSTHOG_HOST` to `.env` -3. The app uses a reverse proxy via Next.js rewrites to bypass ad blockers (configured in `next.config.ts`) +- Confirm `/dashboard` and `/onboarding` are protected. +- Confirm authenticated users without active org are redirected to `/onboarding`. +- Confirm cross-org data reads/writes are denied in Convex. diff --git a/docs/getting-started.md b/docs/getting-started.md index 57db3f4..3a2023c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -5,7 +5,7 @@ | Layer | Tech | | -------------- | ------------------------------------ | | Framework | Next.js 16 (App Router) | -| Auth | Clerk | +| Auth | Clerk (Organizations) | | Database | Convex | | Styling | Tailwind CSS 4 + shadcn/ui + Base UI | | Analytics | PostHog (reverse-proxied) | @@ -13,13 +13,17 @@ | Fonts | Geist Sans / Mono / Pixel Square | | Deployment | Vercel | +## Branch Scope + +This branch is the dedicated multi-tenant variant (`feat/multi-tenancy`) and is intended for apps that require organization-based tenancy. + ## Prerequisites - Node.js 18+ - pnpm -- A Clerk account -- A Convex project -- (Optional) PostHog & Sentry accounts +- Clerk project with Organizations enabled +- Convex project +- (Optional) PostHog and Sentry accounts ## Setup @@ -29,70 +33,50 @@ pnpm install cp .env.example .env ``` -Fill in your `.env` values, then: +Fill `.env`, then run: ```bash -npx convex dev # start Convex dev server -pnpm dev # start Next.js dev server +npx convex dev +pnpm dev ``` Open [http://localhost:3000](http://localhost:3000). -## Project Structure +## Clerk Setup (Required for Tenancy) + +1. Enable Organizations in Clerk Dashboard. +2. Ensure org roles are available (`org:admin`, `org:member`). +3. (Optional) Configure custom organization permissions matching `src/lib/auth/rbac.ts`. +4. Configure org billing plans if using paid tiers. + +## Route Behavior + +- Signed-out users are redirected to sign-in for protected routes. +- Signed-in users without an active org are redirected to `/onboarding`. +- Dashboard usage requires an active organization context. + +## Project Structure Highlights ``` src/ ├── app/ -│ ├── (auth)/ # Sign-in & sign-up pages (Clerk) -│ ├── (dashboard)/ # Protected dashboard -│ ├── (legal)/ # Privacy, terms, cookies -│ ├── (marketing)/ # Landing, features, pricing, about, docs, blog -│ │ └── blog/ # Blog index + [slug] detail pages -│ ├── api/ -│ │ ├── chat/ # Vercel AI SDK chat endpoint -│ │ ├── email/ # Send transactional emails via Resend -│ │ └── health/ # Health check endpoint (rate-limited) -│ ├── onboarding/ # Multi-step onboarding flow for new users -│ ├── layout.tsx # Root layout (providers, metadata, fonts) -│ ├── not-found.tsx # Custom 404 page -│ ├── error.tsx # App-level error boundary -│ ├── global-error.tsx # Root error boundary -│ ├── robots.ts # Robots.txt generation -│ └── sitemap.ts # Sitemap generation -├── components/ -│ ├── ui/ # shadcn/ui primitives -│ ├── billing/ # Upgrade button, plan gating -│ ├── dashboard/ # Dashboard shell, sidebar, top nav -│ ├── posthog-*.tsx # PostHog provider, pageview, identify -│ ├── theme-toggle.tsx # Light/dark/system theme switcher -│ ├── header.tsx # Marketing header -│ └── footer-1.tsx # Marketing footer (includes theme toggle) -├── hooks/ -│ ├── use-mobile.ts # Responsive breakpoint hook -│ ├── use-onboarding.ts # Check onboarding status & redirect logic -│ ├── use-sync-user.ts # Syncs Clerk user to Convex -│ └── use-user-plan.ts # Reads current billing plan +│ ├── (dashboard)/onboarding/page.tsx +│ ├── api/chat/route.ts +│ └── api/email/route.ts ├── lib/ -│ ├── blog.ts # Blog post data & helpers -│ ├── constants.ts # SEO, routes, structured data config -│ ├── emails/ # Email templates + Resend send helper -│ ├── files/ # Shared file upload limits/types/formatting config -│ ├── ai/ # AI chat config (model, prompt, rate limits) -│ │ └── tools/ # AI tool registry for chat -│ ├── rate-limit.ts # In-memory sliding window rate limiter -│ ├── structured-data.tsx # JSON-LD components -│ ├── sentry.ts # Sentry helper wrappers -│ ├── convex-client-provider.tsx # Convex + Clerk provider -│ └── utils.ts # cn() class merge utility +│ ├── auth/rbac.ts +│ └── convex-client-provider.tsx convex/ -├── schema.ts # Database schema -├── users.ts # User queries & mutations -└── auth.config.ts # Clerk JWT config for Convex +├── lib/auth.ts +├── schema.ts +├── users.ts +├── files.ts +└── chat.ts ``` ## Environment Variables -See `.env.example` for the full list. Key ones: +See `.env.example` for full list. | Variable | Purpose | | ---------------------------------------- | ----------------------------------------------------------------------- | @@ -101,172 +85,12 @@ See `.env.example` for the full list. Key ones: | `NEXT_PUBLIC_CONVEX_URL` | Convex deployment URL | | `CLERK_JWT_ISSUER_DOMAIN` | Clerk JWT issuer (Convex) | | `AI_GATEWAY_API_KEY` | Vercel AI Gateway key for `/api/chat` | -| `AI_CHAT_MODEL` | Model ID (default: `openai/gpt-4.1-mini`) | -| `AI_CHAT_SYSTEM_PROMPT` | Base system prompt for assistant behavior | -| `AI_CHAT_TOOLS` | Comma-separated enabled tools (default: `getCurrentDateTime,calculate`) | -| `AI_CHAT_MAX_STEPS` | Max model steps/tool calls per response (default: `5`) | -| `AI_CHAT_RATE_LIMIT_MAX_REQUESTS` | Max chat requests per window per user/IP (default: `20`) | -| `AI_CHAT_RATE_LIMIT_WINDOW_MS` | Chat rate-limit window in ms (default: `60000`) | -| `AI_CHAT_ENFORCE_LIFETIME_MESSAGE_LIMIT` | Enable/disable lifetime per-user message cap (default: `true`) | -| `AI_CHAT_LIFETIME_MESSAGE_LIMIT` | Lifetime message cap when enabled (default: `1`) | -| `AI_CHAT_HISTORY_ENABLED` | Enable/disable Convex chat history persistence (default: `true`) | -| `AI_CHAT_HISTORY_MAX_MESSAGE_LENGTH` | Max chars per persisted chat message (default: `8000`) | -| `AI_CHAT_HISTORY_MAX_MESSAGES_PER_THREAD` | Max persisted messages per chat thread (default: `120`) | -| `AI_CHAT_HISTORY_MAX_THREADS` | Max chat threads per user (default: `50`) | -| `AI_CHAT_HISTORY_THREAD_TITLE_MAX_LENGTH` | Max chars for auto-generated chat titles (default: `80`) | -| `AI_CHAT_HISTORY_QUERY_LIMIT` | Max persisted messages returned to chat UI (default: `200`) | -| `FILE_IMAGE_UPLOAD_RATE_LIMIT_MAX_UPLOADS` | Max image uploads per user in each window (default: `10`) | -| `FILE_IMAGE_UPLOAD_RATE_LIMIT_WINDOW_MS` | Image upload rate-limit window in ms (default: `60000`) | | `RESEND_API_KEY` | Resend API key for transactional emails | -| `RESEND_FROM_EMAIL` | Sender address (optional, defaults to `onboarding@resend.dev`) | | `NEXT_PUBLIC_POSTHOG_KEY` | PostHog project API key | | `NEXT_PUBLIC_POSTHOG_HOST` | PostHog ingest host | -| `SENTRY_ORG` / `SENTRY_PROJECT` | Sentry source map uploads | - -## Scripts - -```bash -pnpm dev # Start dev server -pnpm build # Production build -pnpm start # Start production server -pnpm lint # ESLint -npx convex dev # Convex dev mode -``` - -## Blog - -Posts live in `src/lib/blog.ts` as a simple array, no MDX or CMS needed. Add a new object to `BLOG_POSTS` and it appears on `/blog` with its own `/blog/[slug]` page, sitemap entry, and JSON-LD structured data automatically. - -## API Routes - -- `GET /api/health` - returns uptime and a timestamp, rate-limited to 30 req/min per IP. -- `POST /api/chat` - streams AI responses for `/dashboard/chat` via Vercel AI SDK, protected by Clerk auth and rate-limited. -- `POST /api/email` - sends a transactional email to the authenticated user via Resend. Protected by Clerk auth and rate-limited to 10 req/min per IP. See the [Email (Resend)](#email-resend) section below. - -## AI Chat Tool Registry - -Chat tools are defined in `src/lib/ai/tools/registry.ts` and injected into `POST /api/chat`. - -- Add a new tool by defining it in `allChatTools`. -- Enable/disable tools with `AI_CHAT_TOOLS` (comma-separated names). -- Keep route logic unchanged while extending capabilities for builders. - -## File Module Config - -File constraints and formatting are centralized in `src/lib/files/config.ts`. - -- Set allowed MIME types/extensions in `FILE_TYPE_CATALOG`. -- Set limits in `FILE_STORAGE_LIMITS` (`maxFileSizeBytes`, `maxFilesPerUser`). -- Frontend upload UI, upload hook, and Convex file mutations all consume this config. - -## Rate Limiting - -`src/lib/rate-limit.ts` provides a sliding window rate limiter. Use it in any API route: - -```ts -import { rateLimit } from "@/lib/rate-limit"; - -const limiter = rateLimit({ interval: 60_000, limit: 10 }); - -export async function GET(req: Request) { - const ip = req.headers.get("x-forwarded-for") ?? "unknown"; - const { success, remaining, reset } = limiter.check(ip); - - if (!success) { - return Response.json({ error: "Too many requests" }, { status: 429 }); - } - - return Response.json({ ok: true }); -} -``` - -> **Note:** This is in-memory and resets on cold starts. For production multi-instance deployments, swap it with Upstash Redis or similar. - -## Email (Resend) - -Transactional emails are sent via [Resend](https://resend.com). Templates and the send helper live in `src/lib/emails/`. - -### Setup - -1. Create an account at [resend.com](https://resend.com) and grab your API key. -2. Add the key to `.env`: - ``` - RESEND_API_KEY=re_... - RESEND_FROM_EMAIL=hello@yourdomain.com - ``` -3. If you are testing locally without a verified domain, leave `RESEND_FROM_EMAIL` unset and Resend will use its sandbox sender (`onboarding@resend.dev`). - -### Templates - -Each template exports a function returning `{ subject, html }`: - -- `welcomeEmail({ name })` - welcome email for new sign-ups -- `planChangedEmail({ name, previousPlan, newPlan })` - plan upgrade/downgrade notification - -### Sending emails server-side - -Use the `sendEmail` helper in any server context (API routes, server actions): - -```ts -import { sendEmail, welcomeEmail } from "@/lib/emails"; - -const { subject, html } = welcomeEmail({ name: "Ege" }); -const result = await sendEmail({ to: "ege@example.com", subject, html }); - -if (!result.success) { - console.error("Email failed:", result.error); -} -``` - -### API route - -`POST /api/email` sends a template email to the currently authenticated user. It reads the user's email from Clerk, so the caller only provides the template and its data. - -```json -{ "template": "welcome", "name": "Ege" } -``` - -```json -{ - "template": "plan-changed", - "name": "Ege", - "previousPlan": "free", - "newPlan": "pro" -} -``` - -The route is Clerk-authenticated and rate-limited to 10 requests per minute. - -## Onboarding - -New users are automatically redirected to `/onboarding` on their first dashboard visit. The onboarding flow is a clean, multi-step process that: - -- Welcomes users and shows what to expect -- Reviews their profile information from Clerk -- Shows next steps and tips -- Tracks completion state in Convex - -### How it works - -1. **Tracking**: The `onboardingCompleted` and `onboardingStep` fields in the Convex `users` table track progress. -2. **Hook**: The `useOnboarding` hook checks status and redirects appropriately. -3. **Auto-redirect**: The `DashboardShell` component calls `useOnboarding()` to enforce the flow. -4. **Skip option**: Users can skip onboarding and come back later. - -### Customizing steps - -Edit `src/app/(dashboard)/onboarding/page.tsx` to add your own steps. The current steps are: - -1. **Welcome** - Introduction and overview -2. **Profile** - Show user info from Clerk -3. **Preferences** - Final step before completion - -Add new steps by: - -1. Adding the step to the `STEPS` array -2. Adding a case to the `renderStep()` function -3. Updating the `OnboardingStep` type in `convex/users.ts` -### Reset onboarding +## Notes -For testing, you can reset a user's onboarding via the Convex dashboard or by calling the `resetOnboarding` mutation. +- Chat and file modules are workspace-shared and org-scoped. +- Onboarding remains account-level per user. +- Billing checks are organization-scoped in this branch. diff --git a/docs/multi-tenancy.md b/docs/multi-tenancy.md new file mode 100644 index 0000000..68ff536 --- /dev/null +++ b/docs/multi-tenancy.md @@ -0,0 +1,121 @@ +# Multi Tenancy + +## Overview + +This branch implements a B2B shared-user-pool architecture using Clerk Organizations and Convex tenant-scoped data. + +- Users sign in once and can belong to multiple organizations. +- The active Clerk organization (`orgId`) is the tenant context. +- Personal workspaces are disabled in-app (`hidePersonal=true`). +- Users without an active organization are redirected to `/onboarding`. + +## Tenancy Model + +### Tenant boundary + +All workspace data uses Clerk `orgId` as the primary tenant key. + +- `files.orgId` +- `chatThreads.orgId` +- `chatMessages.orgId` + +### Per-user data + +User profile and onboarding remain account-level and are stored in `users` by Clerk user ID (`clerkId`). + +## RBAC Model + +RBAC constants live in `src/lib/auth/rbac.ts` and are enforced in Convex via `convex/lib/auth.ts`. + +### Roles + +- `org:admin` +- `org:member` + +### Permissions + +- `org:files:read` +- `org:files:create` +- `org:files:delete` +- `org:chat:read` +- `org:chat:create` +- `org:workspace_settings:manage` +- `org:workspace_members:manage` +- `org:workspace_billing:manage` + +### Default fallback matrix + +- `org:admin`: all permissions +- `org:member`: read/create for files and chat +- Destructive/governance actions (for example file deletion) are admin-only by default. + +## Convex Access Control + +Convex functions fail closed if: + +- user is unauthenticated +- active organization is missing +- organization role is missing +- required permission is not satisfied + +Guards are centralized in `convex/lib/auth.ts`. + +## Route Guards + +`src/proxy.ts` enforces: + +- auth protection for `/dashboard(.*)` and `/onboarding(.*)` +- redirect to `/onboarding` when authenticated but no active org + +## Billing Scope + +Billing checks are organization-scoped. + +- `PricingTable` is configured as `` +- plan checks use Clerk `has({ plan: "organizations" })` in active org context +- Convex entitlement checks derive plan from organization claims in the auth token (`org_plan`-style claim keys). +- `users.plan` is informational only and is not used for tenant entitlement decisions. + +## Deployment Baseline Requirement + +This variant is a fresh-baseline multi-tenant branch. It is intentionally not backward compatible with legacy single-tenant `files`/`chat` rows. + +- Use a fresh Convex deployment (recommended), or clear legacy `files`, `chatThreads`, and `chatMessages` data before rollout. +- Deploying this schema over existing single-tenant data can cause schema validation/runtime errors and missing history in org-scoped views. +- No automatic backfill/migration is provided in this branch by design. + +## Workspace Features + +### Files + +Files are workspace-shared. + +- Upload/read scoped to active org +- Delete is admin-only by default matrix +- Metadata validation and upload abuse protections remain enabled + +### Chat + +Chat threads/messages are workspace-shared. + +- list/read/create scoped to active org +- thread and message bounds still enforced via `chatHistoryConfig` + +## Clerk Setup Checklist + +1. Enable Organizations in Clerk. +2. Configure organization roles (`org:admin`, `org:member`). +3. (Optional) Configure custom permissions matching keys above. +4. If using Clerk Billing, configure organization plans/features. +5. Ensure Convex auth issuer is set via `CLERK_JWT_ISSUER_DOMAIN`. +6. Configure Clerk JWT template `convex` with: + - `org_id`: `{{org.id}}` + - `org_role`: `{{org.role}}` + - `org_permissions`: `{{org.permissions}}` + +## Security Notes + +- Every Convex workspace read/write checks `orgId`. +- Cross-tenant access is blocked by tenant key checks and permission checks. +- API routes enforce auth and active organization before processing. +- Failures return explicit `401`/`403` responses. diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index 85f9d0b..39b5577 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "my", + "name": "shipr", "version": "0.1.0", "private": true, "scripts": { @@ -9,44 +9,44 @@ "lint": "eslint" }, "dependencies": { - "@ai-sdk/react": "^3.0.88", + "@ai-sdk/react": "^3.0.99", "@base-ui/react": "^1.2.0", - "@clerk/nextjs": "^6.37.4", - "@clerk/themes": "^2.4.52", + "@clerk/nextjs": "^6.38.1", + "@clerk/themes": "^2.4.55", "@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/react": "^1.1.5", "@radix-ui/react-slot": "^1.2.4", "@sentry/nextjs": "^10.39.0", "@vercel/analytics": "^1.6.1", "@vercel/speed-insights": "^1.3.1", - "ai": "^6.0.86", + "ai": "^6.0.97", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "convex": "^1.31.7", + "convex": "^1.32.0", "geist": "^1.7.0", - "marked": "^17.0.2", - "motion": "^12.34.0", + "marked": "^17.0.3", + "motion": "^12.34.3", "next": "16.1.6", "next-themes": "^0.4.6", - "posthog-js": "^1.347.2", + "posthog-js": "^1.352.0", "react": "19.2.4", "react-dom": "19.2.4", "resend": "^6.9.2", "shadcn": "^3.8.5", "sonner": "^2.0.7", - "tailwind-merge": "^3.4.1", + "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", "zod": "^4.3.6" }, "devDependencies": { - "@tailwindcss/postcss": "^4.1.18", - "@types/node": "^25.2.3", + "@tailwindcss/postcss": "^4.2.0", + "@types/node": "^25.3.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "babel-plugin-react-compiler": "1.0.0", - "eslint": "^10.0.0", + "eslint": "^9.39.3", "eslint-config-next": "16.1.6", - "tailwindcss": "^4.1.18", + "tailwindcss": "^4.2.0", "typescript": "^5.9.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb4e324..000c01a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,17 +9,17 @@ importers: .: dependencies: '@ai-sdk/react': - specifier: ^3.0.88 - version: 3.0.88(react@19.2.4)(zod@4.3.6) + specifier: ^3.0.99 + version: 3.0.99(react@19.2.4)(zod@4.3.6) '@base-ui/react': specifier: ^1.2.0 version: 1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@clerk/nextjs': - specifier: ^6.37.4 - version: 6.37.4(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^6.38.1 + version: 6.38.1(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@clerk/themes': - specifier: ^2.4.52 - version: 2.4.52(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^2.4.55 + version: 2.4.55(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@hugeicons/core-free-icons': specifier: ^3.1.1 version: 3.1.1 @@ -39,8 +39,8 @@ importers: specifier: ^1.3.1 version: 1.3.1(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) ai: - specifier: ^6.0.86 - version: 6.0.86(zod@4.3.6) + specifier: ^6.0.97 + version: 6.0.97(zod@4.3.6) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -48,17 +48,17 @@ importers: specifier: ^2.1.1 version: 2.1.1 convex: - specifier: ^1.31.7 - version: 1.31.7(@clerk/clerk-react@5.60.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + specifier: ^1.32.0 + version: 1.32.0(@clerk/clerk-react@5.61.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) geist: specifier: ^1.7.0 version: 1.7.0(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) marked: - specifier: ^17.0.2 - version: 17.0.2 + specifier: ^17.0.3 + version: 17.0.3 motion: - specifier: ^12.34.0 - version: 12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^12.34.3 + version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: specifier: 16.1.6 version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -66,8 +66,8 @@ importers: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) posthog-js: - specifier: ^1.347.2 - version: 1.347.2 + specifier: ^1.352.0 + version: 1.352.0 react: specifier: 19.2.4 version: 19.2.4 @@ -79,13 +79,13 @@ importers: version: 6.9.2 shadcn: specifier: ^3.8.5 - version: 3.8.5(@types/node@25.2.3)(typescript@5.9.3) + version: 3.8.5(@types/node@25.3.0)(typescript@5.9.3) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tailwind-merge: - specifier: ^3.4.1 - version: 3.4.1 + specifier: ^3.5.0 + version: 3.5.0 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -94,11 +94,11 @@ importers: version: 4.3.6 devDependencies: '@tailwindcss/postcss': - specifier: ^4.1.18 - version: 4.1.18 + specifier: ^4.2.0 + version: 4.2.0 '@types/node': - specifier: ^25.2.3 - version: 25.2.3 + specifier: ^25.3.0 + version: 25.3.0 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -109,22 +109,22 @@ importers: specifier: 1.0.0 version: 1.0.0 eslint: - specifier: ^10.0.0 - version: 10.0.0(jiti@2.6.1) + specifier: ^9.39.3 + version: 9.39.3(jiti@2.6.1) eslint-config-next: specifier: 16.1.6 - version: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3) + version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) tailwindcss: - specifier: ^4.1.18 - version: 4.1.18 + specifier: ^4.2.0 + version: 4.2.0 typescript: specifier: ^5.9.3 version: 5.9.3 packages: - '@ai-sdk/gateway@3.0.46': - resolution: {integrity: sha512-zH1UbNRjG5woOXXFOrVCZraqZuFTtmPvLardMGcgLkzpxKV0U3tAGoyWKSZ862H+eBJfI/Hf2yj/zzGJcCkycg==} + '@ai-sdk/gateway@3.0.53': + resolution: {integrity: sha512-QT3FEoNARMRlk8JJVR7L98exiK9C8AGfrEJVbRxBT1yIXKs/N19o/+PsjTRVsARgDJNcy9JbJp1FspKucEat0Q==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -139,8 +139,8 @@ packages: resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} engines: {node: '>=18'} - '@ai-sdk/react@3.0.88': - resolution: {integrity: sha512-fsUsDP0S+AAmxwgzxYhn9MQKmnAg7AttV3yrSw3bvI5MVfl0LBD3ViRuGNqY8S1DkkDfEF4BLom/nkbD1zY4bQ==} + '@ai-sdk/react@3.0.99': + resolution: {integrity: sha512-xMsp5br4Dpr/3BYq/jrE8q4YLgViU1KHVq8VB0+dzdLJFU3jKA83uoxpbWqzV/edQOBPgGBSb2CgmV5v77rvzA==} engines: {node: '>=18'} peerDependencies: react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 @@ -313,27 +313,27 @@ packages: '@types/react': optional: true - '@clerk/backend@2.31.0': - resolution: {integrity: sha512-4Cg5IUSaSR0ecTk1NGlRpxmXKaCJqrXqS5BppyEPMwYYIrLepJGQMNeTWfVr3n1ffJoXxqLQL6X5ct/P2nLDjQ==} + '@clerk/backend@2.32.0': + resolution: {integrity: sha512-bUCrENRjJzqyk6lsiQQnLs4bsejfD7z2Wmpcd5XN0YXklSvtgYd4ILB8+QLCv+xKVIe5WraA+Iv0ZrKb0fyZUw==} engines: {node: '>=18.17.0'} - '@clerk/clerk-react@5.60.1': - resolution: {integrity: sha512-Z7LAJKvcieGSFB3Q0f840o8Lh7VauvBjc+aDElIt6OHaJRjWcjhFcRiL2Fvg9YkRG942IZN8RHl7iKUzAU5SIQ==} + '@clerk/clerk-react@5.61.1': + resolution: {integrity: sha512-FB6Dt6iwNR//UG/Xt61+WJKj6wtxvPtrF4CgO3Vm3GWb6xyFPZUFRrcdE4pZrF1glCVZ1TXEAAvDMFOAM4ybRw==} engines: {node: '>=18.17.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 - '@clerk/nextjs@6.37.4': - resolution: {integrity: sha512-Cl0eYQVdAN2ilLf9lT4UK1WWNWrqv1E5Z+Hx4F5zC+p0Vw4W9yhxwotDalYXgBLXFuLnwQBm5jB460X+gEFtVA==} + '@clerk/nextjs@6.38.1': + resolution: {integrity: sha512-QPweHCcL2LbFtjO25ymlQjBthk2L2oh1Jjh3+zV2iWOxlf3jmKKHs8kPfHeH6p7ndZKp8SLH45U8zmVZLCteiA==} engines: {node: '>=18.17.0'} peerDependencies: next: ^13.5.7 || ^14.2.25 || ^15.2.3 || ^16 react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 - '@clerk/shared@3.45.0': - resolution: {integrity: sha512-u4MlyEQy+QnGiQqwwqznplJ59el3k05tgaRyh9O3KSxWa84Br4JCXRuV9yYhA0+7bvgUPE7nLlX2byWmf7QOAA==} + '@clerk/shared@3.47.0': + resolution: {integrity: sha512-EDWFysptTc58X96MGQIZ3LlcMFKLG+rhIF9kf6n+wnyQDWnfuyA8I8ge7GbjfUXMf00c//A/CGSjg7t/oupUpw==} engines: {node: '>=18.17.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -344,12 +344,12 @@ packages: react-dom: optional: true - '@clerk/themes@2.4.52': - resolution: {integrity: sha512-27UWgn+QKnUE02yu9MZ6UgTYg7k5y7qig76WFwIviVEFxXYLk0o4ttqvDjvJKTfe2UMCkZLUmyglqGErlvvPzQ==} + '@clerk/themes@2.4.55': + resolution: {integrity: sha512-j9q8NtAaI2f7vNBuO2RAUDmAebab2UoZCXshlTzEhsbB1UH+94fPs4KyUlsbrSNxIJNfTrM2IKxAZKos3gcCJw==} engines: {node: '>=18.17.0'} - '@clerk/types@4.101.15': - resolution: {integrity: sha512-Tb9FYlBQcUkyeX4ILMpNPzvZPwokk/gsDlWOayV+PQKcWvhWD2+xZYrXGv4eaxqIcbXLAMaUo8Gal+lVjQFDeg==} + '@clerk/types@4.101.18': + resolution: {integrity: sha512-huTv4ESnNK5ujCSc0vUNtK2k5xMDOP5C96qOUPB0AZyOWeMYEou5tHDua2NOlgFZAS/M+dJBOffohbiO2mLAhw==} engines: {node: '>=18.17.0'} '@dotenvx/dotenvx@1.52.0': @@ -537,25 +537,33 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.23.1': - resolution: {integrity: sha512-uVSdg/V4dfQmTjJzR0szNczjOH/J+FyUMMjYtr07xFRXR7EDf9i1qdxrD0VusZH9knj1/ecxzCQQxyic5NzAiA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/config-helpers@0.5.2': - resolution: {integrity: sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/core@1.1.0': - resolution: {integrity: sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/object-schema@3.0.1': - resolution: {integrity: sha512-P9cq2dpr+LU8j3qbLygLcSZrl2/ds/pUpfnHNNuk5HW7mnngHs+6WSq5C9mO3rqRX8A1poxqLTC9cu0KOyJlBg==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/eslintrc@3.3.3': + resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/plugin-kit@0.6.0': - resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/js@9.39.3': + resolution: {integrity: sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -794,10 +802,6 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} - '@isaacs/cliui@9.0.0': - resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} - engines: {node: '>=18'} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1185,11 +1189,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@posthog/core@1.22.0': - resolution: {integrity: sha512-WkmOnq95aAOu6yk6r5LWr5cfXsQdpVbWDCwOxQwxSne8YV6GuZET1ziO5toSQXgrgbdcjrSz2/GopAfiL6iiAA==} + '@posthog/core@1.23.1': + resolution: {integrity: sha512-GViD5mOv/mcbZcyzz3z9CS0R79JzxVaqEz4sP5Dsea178M/j3ZWe6gaHDZB9yuyGfcmIMQ/8K14yv+7QrK4sQQ==} - '@posthog/types@1.347.2': - resolution: {integrity: sha512-aT+r/7jXOzPmUHO6sutoWzczPcYIZyhmWt1f1OvY4zKC7Pwp/ZsJWKFTxjV02p0PZz96AE83eLTe7w7b6tjhIw==} + '@posthog/types@1.352.0': + resolution: {integrity: sha512-pp7VBMlkhlLmv2TyOoss028lPPD4ElnZlX5y3hqq6oijK5BMZbjVuTAgvFYNLiKbuze/i5ndFGyXTtfCwlMQeA==} '@prisma/instrumentation@7.2.0': resolution: {integrity: sha512-Rh9Z4x5kEj1OdARd7U18AtVrnL6rmLSI0qYShaB4W7Wx5BKbgzndWF+QnuzMb7GLfVdlT5aYCXoPQVYuYtVu0g==} @@ -1262,141 +1266,141 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.57.1': - resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} + '@rollup/rollup-android-arm-eabi@4.58.0': + resolution: {integrity: sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.57.1': - resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} + '@rollup/rollup-android-arm64@4.58.0': + resolution: {integrity: sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.57.1': - resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} + '@rollup/rollup-darwin-arm64@4.58.0': + resolution: {integrity: sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.57.1': - resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} + '@rollup/rollup-darwin-x64@4.58.0': + resolution: {integrity: sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.57.1': - resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} + '@rollup/rollup-freebsd-arm64@4.58.0': + resolution: {integrity: sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.57.1': - resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} + '@rollup/rollup-freebsd-x64@4.58.0': + resolution: {integrity: sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.57.1': - resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} + '@rollup/rollup-linux-arm-gnueabihf@4.58.0': + resolution: {integrity: sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.57.1': - resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + '@rollup/rollup-linux-arm-musleabihf@4.58.0': + resolution: {integrity: sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.57.1': - resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + '@rollup/rollup-linux-arm64-gnu@4.58.0': + resolution: {integrity: sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.57.1': - resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + '@rollup/rollup-linux-arm64-musl@4.58.0': + resolution: {integrity: sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.57.1': - resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + '@rollup/rollup-linux-loong64-gnu@4.58.0': + resolution: {integrity: sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.57.1': - resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + '@rollup/rollup-linux-loong64-musl@4.58.0': + resolution: {integrity: sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.57.1': - resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + '@rollup/rollup-linux-ppc64-gnu@4.58.0': + resolution: {integrity: sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.57.1': - resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + '@rollup/rollup-linux-ppc64-musl@4.58.0': + resolution: {integrity: sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.57.1': - resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} + '@rollup/rollup-linux-riscv64-gnu@4.58.0': + resolution: {integrity: sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.57.1': - resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + '@rollup/rollup-linux-riscv64-musl@4.58.0': + resolution: {integrity: sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.57.1': - resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} + '@rollup/rollup-linux-s390x-gnu@4.58.0': + resolution: {integrity: sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.57.1': - resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} + '@rollup/rollup-linux-x64-gnu@4.58.0': + resolution: {integrity: sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.57.1': - resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + '@rollup/rollup-linux-x64-musl@4.58.0': + resolution: {integrity: sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.57.1': - resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} + '@rollup/rollup-openbsd-x64@4.58.0': + resolution: {integrity: sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.57.1': - resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} + '@rollup/rollup-openharmony-arm64@4.58.0': + resolution: {integrity: sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.57.1': - resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + '@rollup/rollup-win32-arm64-msvc@4.58.0': + resolution: {integrity: sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.57.1': - resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} + '@rollup/rollup-win32-ia32-msvc@4.58.0': + resolution: {integrity: sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.57.1': - resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} + '@rollup/rollup-win32-x64-gnu@4.58.0': + resolution: {integrity: sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.57.1': - resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} + '@rollup/rollup-win32-x64-msvc@4.58.0': + resolution: {integrity: sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==} cpu: [x64] os: [win32] @@ -1566,69 +1570,69 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@tailwindcss/node@4.1.18': - resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} + '@tailwindcss/node@4.2.0': + resolution: {integrity: sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q==} - '@tailwindcss/oxide-android-arm64@4.1.18': - resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-android-arm64@4.2.0': + resolution: {integrity: sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw==} + engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.1.18': - resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-darwin-arm64@4.2.0': + resolution: {integrity: sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA==} + engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.1.18': - resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-darwin-x64@4.2.0': + resolution: {integrity: sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg==} + engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.1.18': - resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-freebsd-x64@4.2.0': + resolution: {integrity: sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw==} + engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': - resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': + resolution: {integrity: sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg==} + engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': - resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': + resolution: {integrity: sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': - resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm64-musl@4.2.0': + resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': - resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-x64-gnu@4.2.0': + resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.1.18': - resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-x64-musl@4.2.0': + resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.1.18': - resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} + '@tailwindcss/oxide-wasm32-wasi@4.2.0': + resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -1639,24 +1643,24 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': - resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': + resolution: {integrity: sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA==} + engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': - resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-win32-x64-msvc@4.2.0': + resolution: {integrity: sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ==} + engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.1.18': - resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} - engines: {node: '>= 10'} + '@tailwindcss/oxide@4.2.0': + resolution: {integrity: sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ==} + engines: {node: '>= 20'} - '@tailwindcss/postcss@4.1.18': - resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} + '@tailwindcss/postcss@4.2.0': + resolution: {integrity: sha512-u6YBacGpOm/ixPfKqfgrJEjMfrYmPD7gEFRoygS/hnQaRtV0VCBdpkx5Ouw9pnaLRwwlgGCuJw8xLpaR0hOrQg==} '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -1673,9 +1677,6 @@ packages: '@types/eslint@9.6.1': resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} - '@types/esrecurse@4.3.1': - resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} - '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1688,11 +1689,8 @@ packages: '@types/mysql@2.15.27': resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} - '@types/node@20.19.33': - resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} - - '@types/node@25.2.3': - resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} + '@types/node@25.3.0': + resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} '@types/pg-pool@2.0.7': resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==} @@ -1720,63 +1718,63 @@ packages: '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} - '@typescript-eslint/eslint-plugin@8.55.0': - resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} + '@typescript-eslint/eslint-plugin@8.56.0': + resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.55.0 - eslint: ^8.57.0 || ^9.0.0 + '@typescript-eslint/parser': ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.55.0': - resolution: {integrity: sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==} + '@typescript-eslint/parser@8.56.0': + resolution: {integrity: sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.55.0': - resolution: {integrity: sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==} + '@typescript-eslint/project-service@8.56.0': + resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.55.0': - resolution: {integrity: sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==} + '@typescript-eslint/scope-manager@8.56.0': + resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.55.0': - resolution: {integrity: sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==} + '@typescript-eslint/tsconfig-utils@8.56.0': + resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.55.0': - resolution: {integrity: sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==} + '@typescript-eslint/type-utils@8.56.0': + resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.55.0': - resolution: {integrity: sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==} + '@typescript-eslint/types@8.56.0': + resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.55.0': - resolution: {integrity: sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==} + '@typescript-eslint/typescript-estree@8.56.0': + resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.55.0': - resolution: {integrity: sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==} + '@typescript-eslint/utils@8.56.0': + resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.55.0': - resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} + '@typescript-eslint/visitor-keys@8.56.0': + resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@unrs/resolver-binding-android-arm-eabi@1.11.1': @@ -2006,8 +2004,8 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} hasBin: true @@ -2019,8 +2017,8 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - ai@6.0.86: - resolution: {integrity: sha512-U2W2LBCHA/pr0Ui7vmmsjBiLEzBbZF3yVHNy7Rbzn7IX+SvoQPFM5rN74hhfVzZoE8zBuGD4nLLk+j0elGacvQ==} + ai@6.0.97: + resolution: {integrity: sha512-eZIAcBymwGhBwncRH/v9pillZNMeRCDkc4BwcvwXerXd7sxjVxRis3ZNCNCpP02pVH4NLs81ljm4cElC4vbNcQ==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -2046,8 +2044,8 @@ packages: peerDependencies: ajv: ^8.8.2 - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -2144,12 +2142,13 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@4.0.2: - resolution: {integrity: sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==} + balanced-match@4.0.3: + resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} engines: {node: 20 || >=22} - baseline-browser-mapping@2.9.19: - resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + baseline-browser-mapping@2.10.0: + resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} + engines: {node: '>=6.0.0'} hasBin: true binary-extensions@2.3.0: @@ -2209,6 +2208,10 @@ packages: caniuse-lite@1.0.30001770: resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -2288,8 +2291,8 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - convex@1.31.7: - resolution: {integrity: sha512-PtNMe1mAIOvA8Yz100QTOaIdgt2rIuWqencVXrb4McdhxBHZ8IJ1eXTnrgCC9HydyilGT1pOn+KNqT14mqn9fQ==} + convex@1.32.0: + resolution: {integrity: sha512-5FlajdLpW75pdLS+/CgGH5H6yeRuA+ru50AKJEYbJpmyILUS+7fdTvsdTaQ7ZFXMv0gE8mX4S+S3AtJ94k0mfw==} engines: {node: '>=18.0.0', npm: '>=7.0.0'} hasBin: true peerDependencies: @@ -2463,8 +2466,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.286: - resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + electron-to-chromium@1.5.302: + resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2619,9 +2622,9 @@ packages: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} engines: {node: '>=8.0.0'} - eslint-scope@9.1.0: - resolution: {integrity: sha512-CkWE42hOJsNj9FJRaoMX9waUFYhqY4jmyLFdAdzZr6VaCg3ynLYx4WnOdkaIifGfH4gsUcBTn4OZbHXkpLD0FQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} @@ -2631,13 +2634,13 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint-visitor-keys@5.0.0: - resolution: {integrity: sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.0.0: - resolution: {integrity: sha512-O0piBKY36YSJhlFSG8p9VUdPV/SxxS4FYDWVpr/9GJuMaepzwlf4J8I4ov1b+ySQfDTPhc3DtLaxcT1fN0yqCg==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint@9.39.3: + resolution: {integrity: sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: jiti: '*' @@ -2645,9 +2648,9 @@ packages: jiti: optional: true - espree@11.1.0: - resolution: {integrity: sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} @@ -2799,8 +2802,8 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - framer-motion@12.34.0: - resolution: {integrity: sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==} + framer-motion@12.34.3: + resolution: {integrity: sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -2859,8 +2862,8 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.4.0: - resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} get-intrinsic@1.3.0: @@ -2906,6 +2909,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + globals@16.4.0: resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} engines: {node: '>=18'} @@ -2961,8 +2968,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.11.9: - resolution: {integrity: sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==} + hono@4.12.1: + resolution: {integrity: sha512-hi9afu8g0lfJVLolxElAZGANCTTl6bewIdsRNhaywfP9K8BPf++F2z6OLrYGIinUwpRKzbZHMhPwvc0ZEpAwGw==} engines: {node: '>=16.9.0'} http-errors@2.0.1: @@ -3213,10 +3220,6 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jackspeak@4.2.3: - resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} - engines: {node: 20 || >=22} - jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} @@ -3303,78 +3306,78 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lightningcss-android-arm64@1.30.2: - resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.30.2: - resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.30.2: - resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.30.2: - resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.30.2: - resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.30.2: - resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - lightningcss-linux-arm64-musl@1.30.2: - resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.30.2: - resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.30.2: - resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] - lightningcss-win32-arm64-msvc@1.30.2: - resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.30.2: - resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.30.2: - resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} engines: {node: '>= 12.0.0'} lines-and-columns@1.2.4: @@ -3388,6 +3391,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + log-symbols@6.0.0: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} @@ -3412,8 +3418,8 @@ packages: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} - marked@17.0.2: - resolution: {integrity: sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA==} + marked@17.0.3: + resolution: {integrity: sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==} engines: {node: '>= 20'} hasBin: true @@ -3464,9 +3470,9 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - minimatch@10.2.0: - resolution: {integrity: sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==} - engines: {node: 20 || >=22} + minimatch@10.2.2: + resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} + engines: {node: 18 || 20 || >=22} minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -3478,21 +3484,21 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} - motion-dom@12.34.0: - resolution: {integrity: sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q==} + motion-dom@12.34.3: + resolution: {integrity: sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==} motion-utils@12.29.2: resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} - motion@12.34.0: - resolution: {integrity: sha512-01Sfa/zgsD/di8zA/uFW5Eb7/SPXoGyUfy+uMRMW5Spa8j0z/UbfQewAYvPMYFCXRlyD6e5aLHh76TxeeJD+RA==} + motion@12.34.3: + resolution: {integrity: sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -3574,6 +3580,10 @@ packages: engines: {node: '>=10.5.0'} deprecated: Use your platform's native DOMException instead + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -3791,15 +3801,15 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} - posthog-js@1.347.2: - resolution: {integrity: sha512-hDbsSU30gfNhC11cBYSPpwUYB4DglbCN2G8W8NPIR/KXhT03shmuxabra/uaoI4blkr8SSSpxwvYV4gGa3hXrA==} + posthog-js@1.352.0: + resolution: {integrity: sha512-LxLKyoE+Y2z+WQ8CTO3PqQQDBuz64mHLJUoRuAYNXmp3vtxzrygZEz7UNnCT+BZ4/G44Qeq6JDYk1TRS7pIRDA==} powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} - preact@10.28.3: - resolution: {integrity: sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==} + preact@10.28.4: + resolution: {integrity: sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==} prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} @@ -3925,8 +3935,9 @@ packages: engines: {node: '>= 0.4'} hasBin: true - resolve@2.0.0-next.5: - resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + engines: {node: '>= 0.4'} hasBin: true restore-cursor@5.1.0: @@ -3940,8 +3951,8 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rollup@4.57.1: - resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + rollup@4.58.0: + resolution: {integrity: sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -4165,6 +4176,10 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -4178,6 +4193,10 @@ packages: babel-plugin-macros: optional: true + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + supports-color@8.1.1: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} @@ -4206,11 +4225,11 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} - tailwind-merge@3.4.1: - resolution: {integrity: sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q==} + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - tailwindcss@4.1.18: - resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + tailwindcss@4.2.0: + resolution: {integrity: sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==} tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} @@ -4328,11 +4347,11 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typescript-eslint@8.55.0: - resolution: {integrity: sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==} + typescript-eslint@8.56.0: + resolution: {integrity: sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' typescript@5.9.3: @@ -4344,11 +4363,8 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} @@ -4483,6 +4499,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.3.1: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} @@ -4537,7 +4565,7 @@ packages: snapshots: - '@ai-sdk/gateway@3.0.46(zod@4.3.6)': + '@ai-sdk/gateway@3.0.53(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.8 '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) @@ -4555,10 +4583,10 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/react@3.0.88(react@19.2.4)(zod@4.3.6)': + '@ai-sdk/react@3.0.99(react@19.2.4)(zod@4.3.6)': dependencies: '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - ai: 6.0.86(zod@4.3.6) + ai: 6.0.97(zod@4.3.6) react: 19.2.4 swr: 2.4.0(react@19.2.4) throttleit: 2.1.0 @@ -4796,36 +4824,36 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@clerk/backend@2.31.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@clerk/backend@2.32.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@clerk/shared': 3.45.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@clerk/types': 4.101.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@clerk/shared': 3.47.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@clerk/types': 4.101.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4) standardwebhooks: 1.0.0 tslib: 2.8.1 transitivePeerDependencies: - react - react-dom - '@clerk/clerk-react@5.60.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@clerk/clerk-react@5.61.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@clerk/shared': 3.45.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@clerk/shared': 3.47.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) tslib: 2.8.1 - '@clerk/nextjs@6.37.4(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@clerk/nextjs@6.38.1(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@clerk/backend': 2.31.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@clerk/clerk-react': 5.60.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@clerk/shared': 3.45.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@clerk/types': 4.101.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@clerk/backend': 2.32.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@clerk/clerk-react': 5.61.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@clerk/shared': 3.47.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@clerk/types': 4.101.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) server-only: 0.0.1 tslib: 2.8.1 - '@clerk/shared@3.45.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@clerk/shared@3.47.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: csstype: 3.1.3 dequal: 2.0.3 @@ -4837,17 +4865,17 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@clerk/themes@2.4.52(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@clerk/themes@2.4.55(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@clerk/shared': 3.45.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@clerk/shared': 3.47.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tslib: 2.8.1 transitivePeerDependencies: - react - react-dom - '@clerk/types@4.101.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@clerk/types@4.101.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@clerk/shared': 3.45.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@clerk/shared': 3.47.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) transitivePeerDependencies: - react - react-dom @@ -4962,34 +4990,50 @@ snapshots: '@esbuild/win32-x64@0.27.0': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.0.0(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.3(jiti@2.6.1))': dependencies: - eslint: 10.0.0(jiti@2.6.1) + eslint: 9.39.3(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.23.1': + '@eslint/config-array@0.21.1': dependencies: - '@eslint/object-schema': 3.0.1 + '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 10.2.0 + minimatch: 3.1.2 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.5.2': + '@eslint/config-helpers@0.4.2': dependencies: - '@eslint/core': 1.1.0 + '@eslint/core': 0.17.0 - '@eslint/core@1.1.0': + '@eslint/core@0.17.0': dependencies: '@types/json-schema': 7.0.15 - '@eslint/object-schema@3.0.1': {} + '@eslint/eslintrc@3.3.3': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.3': {} - '@eslint/plugin-kit@0.6.0': + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': dependencies: - '@eslint/core': 1.1.0 + '@eslint/core': 0.17.0 levn: 0.4.1 '@floating-ui/core@1.7.4': @@ -5009,9 +5053,9 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@hono/node-server@1.19.9(hono@4.11.9)': + '@hono/node-server@1.19.9(hono@4.12.1)': dependencies: - hono: 4.11.9 + hono: 4.12.1 '@hugeicons/core-free-icons@3.1.1': {} @@ -5129,31 +5173,31 @@ snapshots: '@inquirer/ansi@1.0.2': {} - '@inquirer/confirm@5.1.21(@types/node@25.2.3)': + '@inquirer/confirm@5.1.21(@types/node@25.3.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.2.3) - '@inquirer/type': 3.0.10(@types/node@25.2.3) + '@inquirer/core': 10.3.2(@types/node@25.3.0) + '@inquirer/type': 3.0.10(@types/node@25.3.0) optionalDependencies: - '@types/node': 25.2.3 + '@types/node': 25.3.0 - '@inquirer/core@10.3.2(@types/node@25.2.3)': + '@inquirer/core@10.3.2(@types/node@25.3.0)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.2.3) + '@inquirer/type': 3.0.10(@types/node@25.3.0) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.2.3 + '@types/node': 25.3.0 '@inquirer/figures@1.0.15': {} - '@inquirer/type@3.0.10(@types/node@25.2.3)': + '@inquirer/type@3.0.10(@types/node@25.3.0)': optionalDependencies: - '@types/node': 25.2.3 + '@types/node': 25.3.0 '@isaacs/cliui@8.0.2': dependencies: @@ -5164,8 +5208,6 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@isaacs/cliui@9.0.0': {} - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5192,7 +5234,7 @@ snapshots: '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.9(hono@4.11.9) + '@hono/node-server': 1.19.9(hono@4.12.1) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -5202,7 +5244,7 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.2.1(express@5.2.1) - hono: 4.11.9 + hono: 4.12.1 jose: 6.1.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -5608,11 +5650,11 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@posthog/core@1.22.0': + '@posthog/core@1.23.1': dependencies: cross-spawn: 7.0.6 - '@posthog/types@1.347.2': {} + '@posthog/types@1.352.0': {} '@prisma/instrumentation@7.2.0(@opentelemetry/api@1.9.0)': dependencies: @@ -5657,9 +5699,9 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@rollup/plugin-commonjs@28.0.1(rollup@4.57.1)': + '@rollup/plugin-commonjs@28.0.1(rollup@4.58.0)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.57.1) + '@rollup/pluginutils': 5.3.0(rollup@4.58.0) commondir: 1.0.1 estree-walker: 2.0.2 fdir: 6.5.0(picomatch@4.0.3) @@ -5667,89 +5709,89 @@ snapshots: magic-string: 0.30.21 picomatch: 4.0.3 optionalDependencies: - rollup: 4.57.1 + rollup: 4.58.0 - '@rollup/pluginutils@5.3.0(rollup@4.57.1)': + '@rollup/pluginutils@5.3.0(rollup@4.58.0)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.57.1 + rollup: 4.58.0 - '@rollup/rollup-android-arm-eabi@4.57.1': + '@rollup/rollup-android-arm-eabi@4.58.0': optional: true - '@rollup/rollup-android-arm64@4.57.1': + '@rollup/rollup-android-arm64@4.58.0': optional: true - '@rollup/rollup-darwin-arm64@4.57.1': + '@rollup/rollup-darwin-arm64@4.58.0': optional: true - '@rollup/rollup-darwin-x64@4.57.1': + '@rollup/rollup-darwin-x64@4.58.0': optional: true - '@rollup/rollup-freebsd-arm64@4.57.1': + '@rollup/rollup-freebsd-arm64@4.58.0': optional: true - '@rollup/rollup-freebsd-x64@4.57.1': + '@rollup/rollup-freebsd-x64@4.58.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + '@rollup/rollup-linux-arm-gnueabihf@4.58.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.57.1': + '@rollup/rollup-linux-arm-musleabihf@4.58.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.57.1': + '@rollup/rollup-linux-arm64-gnu@4.58.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.57.1': + '@rollup/rollup-linux-arm64-musl@4.58.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.57.1': + '@rollup/rollup-linux-loong64-gnu@4.58.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.57.1': + '@rollup/rollup-linux-loong64-musl@4.58.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.57.1': + '@rollup/rollup-linux-ppc64-gnu@4.58.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.57.1': + '@rollup/rollup-linux-ppc64-musl@4.58.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.57.1': + '@rollup/rollup-linux-riscv64-gnu@4.58.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.57.1': + '@rollup/rollup-linux-riscv64-musl@4.58.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.57.1': + '@rollup/rollup-linux-s390x-gnu@4.58.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.57.1': + '@rollup/rollup-linux-x64-gnu@4.58.0': optional: true - '@rollup/rollup-linux-x64-musl@4.57.1': + '@rollup/rollup-linux-x64-musl@4.58.0': optional: true - '@rollup/rollup-openbsd-x64@4.57.1': + '@rollup/rollup-openbsd-x64@4.58.0': optional: true - '@rollup/rollup-openharmony-arm64@4.57.1': + '@rollup/rollup-openharmony-arm64@4.58.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.57.1': + '@rollup/rollup-win32-arm64-msvc@4.58.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.57.1': + '@rollup/rollup-win32-ia32-msvc@4.58.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.57.1': + '@rollup/rollup-win32-x64-gnu@4.58.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.57.1': + '@rollup/rollup-win32-x64-msvc@4.58.0': optional: true '@rtsao/scc@1.1.0': {} @@ -5848,7 +5890,7 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.39.0 - '@rollup/plugin-commonjs': 28.0.1(rollup@4.57.1) + '@rollup/plugin-commonjs': 28.0.1(rollup@4.58.0) '@sentry-internal/browser-utils': 10.39.0 '@sentry/bundler-plugin-core': 4.9.1 '@sentry/core': 10.39.0 @@ -5858,7 +5900,7 @@ snapshots: '@sentry/vercel-edge': 10.39.0 '@sentry/webpack-plugin': 4.9.1(webpack@5.105.2) next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - rollup: 4.57.1 + rollup: 4.58.0 stacktrace-parser: 0.1.11 transitivePeerDependencies: - '@opentelemetry/context-async-hooks' @@ -5967,79 +6009,79 @@ snapshots: dependencies: tslib: 2.8.1 - '@tailwindcss/node@4.1.18': + '@tailwindcss/node@4.2.0': dependencies: '@jridgewell/remapping': 2.3.5 enhanced-resolve: 5.19.0 jiti: 2.6.1 - lightningcss: 1.30.2 + lightningcss: 1.31.1 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.1.18 + tailwindcss: 4.2.0 - '@tailwindcss/oxide-android-arm64@4.1.18': + '@tailwindcss/oxide-android-arm64@4.2.0': optional: true - '@tailwindcss/oxide-darwin-arm64@4.1.18': + '@tailwindcss/oxide-darwin-arm64@4.2.0': optional: true - '@tailwindcss/oxide-darwin-x64@4.1.18': + '@tailwindcss/oxide-darwin-x64@4.2.0': optional: true - '@tailwindcss/oxide-freebsd-x64@4.1.18': + '@tailwindcss/oxide-freebsd-x64@4.2.0': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + '@tailwindcss/oxide-linux-arm64-musl@4.2.0': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + '@tailwindcss/oxide-linux-x64-gnu@4.2.0': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.1.18': + '@tailwindcss/oxide-linux-x64-musl@4.2.0': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.1.18': + '@tailwindcss/oxide-wasm32-wasi@4.2.0': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + '@tailwindcss/oxide-win32-x64-msvc@4.2.0': optional: true - '@tailwindcss/oxide@4.1.18': + '@tailwindcss/oxide@4.2.0': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-x64': 4.1.18 - '@tailwindcss/oxide-freebsd-x64': 4.1.18 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-x64-musl': 4.1.18 - '@tailwindcss/oxide-wasm32-wasi': 4.1.18 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - - '@tailwindcss/postcss@4.1.18': + '@tailwindcss/oxide-android-arm64': 4.2.0 + '@tailwindcss/oxide-darwin-arm64': 4.2.0 + '@tailwindcss/oxide-darwin-x64': 4.2.0 + '@tailwindcss/oxide-freebsd-x64': 4.2.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.0 + '@tailwindcss/oxide-linux-x64-musl': 4.2.0 + '@tailwindcss/oxide-wasm32-wasi': 4.2.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.0 + + '@tailwindcss/postcss@4.2.0': dependencies: '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.1.18 - '@tailwindcss/oxide': 4.1.18 + '@tailwindcss/node': 4.2.0 + '@tailwindcss/oxide': 4.2.0 postcss: 8.5.6 - tailwindcss: 4.1.18 + tailwindcss: 4.2.0 '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 - minimatch: 10.2.0 + minimatch: 10.2.2 path-browserify: 1.0.1 '@tybys/wasm-util@0.10.1': @@ -6049,7 +6091,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 25.2.3 + '@types/node': 25.3.0 '@types/eslint-scope@3.7.7': dependencies: @@ -6061,8 +6103,6 @@ snapshots: '@types/estree': 1.0.8 '@types/json-schema': 7.0.15 - '@types/esrecurse@4.3.1': {} - '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} @@ -6071,15 +6111,11 @@ snapshots: '@types/mysql@2.15.27': dependencies: - '@types/node': 25.2.3 - - '@types/node@20.19.33': - dependencies: - undici-types: 6.21.0 + '@types/node': 25.3.0 - '@types/node@25.2.3': + '@types/node@25.3.0': dependencies: - undici-types: 7.16.0 + undici-types: 7.18.2 '@types/pg-pool@2.0.7': dependencies: @@ -6087,7 +6123,7 @@ snapshots: '@types/pg@8.15.6': dependencies: - '@types/node': 25.2.3 + '@types/node': 25.3.0 pg-protocol: 1.11.0 pg-types: 2.2.0 @@ -6103,22 +6139,22 @@ snapshots: '@types/tedious@4.0.14': dependencies: - '@types/node': 25.2.3 + '@types/node': 25.3.0 '@types/trusted-types@2.0.7': optional: true '@types/validate-npm-package-name@4.0.2': {} - '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/type-utils': 8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.55.0 - eslint: 10.0.0(jiti@2.6.1) + '@typescript-eslint/parser': 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/type-utils': 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.0 + eslint: 9.39.3(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -6126,56 +6162,56 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.55.0 + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 - eslint: 10.0.0(jiti@2.6.1) + eslint: 9.39.3(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.55.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.56.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) - '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.55.0': + '@typescript-eslint/scope-manager@8.56.0': dependencies: - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/visitor-keys': 8.55.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 - '@typescript-eslint/tsconfig-utils@8.55.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 10.0.0(jiti@2.6.1) + eslint: 9.39.3(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.55.0': {} + '@typescript-eslint/types@8.56.0': {} - '@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.55.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/visitor-keys': 8.55.0 + '@typescript-eslint/project-service': 8.56.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 minimatch: 9.0.5 semver: 7.7.4 @@ -6185,21 +6221,21 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.0(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - eslint: 10.0.0(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + eslint: 9.39.3(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.55.0': + '@typescript-eslint/visitor-keys@8.56.0': dependencies: - '@typescript-eslint/types': 8.55.0 - eslint-visitor-keys: 4.2.1 + '@typescript-eslint/types': 8.56.0 + eslint-visitor-keys: 5.0.1 '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true @@ -6357,19 +6393,19 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-import-attributes@1.9.5(acorn@8.15.0): + acorn-import-attributes@1.9.5(acorn@8.16.0): dependencies: - acorn: 8.15.0 + acorn: 8.16.0 - acorn-import-phases@1.0.4(acorn@8.15.0): + acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: - acorn: 8.15.0 + acorn: 8.16.0 - acorn-jsx@5.3.2(acorn@8.15.0): + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: - acorn: 8.15.0 + acorn: 8.16.0 - acorn@8.15.0: {} + acorn@8.16.0: {} agent-base@6.0.2: dependencies: @@ -6379,9 +6415,9 @@ snapshots: agent-base@7.1.4: {} - ai@6.0.86(zod@4.3.6): + ai@6.0.97(zod@4.3.6): dependencies: - '@ai-sdk/gateway': 3.0.46(zod@4.3.6) + '@ai-sdk/gateway': 3.0.53(zod@4.3.6) '@ai-sdk/provider': 3.0.8 '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) '@opentelemetry/api': 1.9.0 @@ -6400,7 +6436,7 @@ snapshots: ajv: 8.18.0 fast-deep-equal: 3.1.3 - ajv@6.12.6: + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -6524,11 +6560,9 @@ snapshots: balanced-match@1.0.2: {} - balanced-match@4.0.2: - dependencies: - jackspeak: 4.2.3 + balanced-match@4.0.3: {} - baseline-browser-mapping@2.9.19: {} + baseline-browser-mapping@2.10.0: {} binary-extensions@2.3.0: {} @@ -6557,7 +6591,7 @@ snapshots: brace-expansion@5.0.2: dependencies: - balanced-match: 4.0.2 + balanced-match: 4.0.3 braces@3.0.3: dependencies: @@ -6565,9 +6599,9 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.9.19 + baseline-browser-mapping: 2.10.0 caniuse-lite: 1.0.30001770 - electron-to-chromium: 1.5.286 + electron-to-chromium: 1.5.302 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -6600,6 +6634,11 @@ snapshots: caniuse-lite@1.0.30001770: {} + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@5.6.2: {} chokidar@3.6.0: @@ -6664,13 +6703,17 @@ snapshots: convert-source-map@2.0.0: {} - convex@1.31.7(@clerk/clerk-react@5.60.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + convex@1.32.0(@clerk/clerk-react@5.61.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): dependencies: esbuild: 0.27.0 prettier: 3.8.1 + ws: 8.18.0 optionalDependencies: - '@clerk/clerk-react': 5.60.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@clerk/clerk-react': 5.61.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 + transitivePeerDependencies: + - bufferutil + - utf-8-validate cookie-signature@1.2.2: {} @@ -6800,7 +6843,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.286: {} + electron-to-chromium@1.5.302: {} emoji-regex@10.6.0: {} @@ -6959,18 +7002,18 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@16.1.6(@typescript-eslint/parser@8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3): + eslint-config-next@16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.1.6 - eslint: 10.0.0(jiti@2.6.1) + eslint: 9.39.3(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@10.0.0(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.0(jiti@2.6.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@10.0.0(jiti@2.6.1)) - eslint-plugin-react: 7.37.5(eslint@10.0.0(jiti@2.6.1)) - eslint-plugin-react-hooks: 7.0.1(eslint@10.0.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-react: 7.37.5(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-react-hooks: 7.0.1(eslint@9.39.3(jiti@2.6.1)) globals: 16.4.0 - typescript-eslint: 8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3) + typescript-eslint: 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -6987,33 +7030,33 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@10.0.0(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 - eslint: 10.0.0(jiti@2.6.1) + eslint: 9.39.3(jiti@2.6.1) get-tsconfig: 4.13.6 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.0(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.0.0(jiti@2.6.1) + '@typescript-eslint/parser': 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.3(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@10.0.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.0(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -7022,9 +7065,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 10.0.0(jiti@2.6.1) + eslint: 9.39.3(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@10.0.0(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -7036,13 +7079,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@10.0.0(jiti@2.6.1)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.3(jiti@2.6.1)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -7052,7 +7095,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 10.0.0(jiti@2.6.1) + eslint: 9.39.3(jiti@2.6.1) hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -7061,18 +7104,18 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@7.0.1(eslint@10.0.0(jiti@2.6.1)): + eslint-plugin-react-hooks@7.0.1(eslint@9.39.3(jiti@2.6.1)): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.0 - eslint: 10.0.0(jiti@2.6.1) + eslint: 9.39.3(jiti@2.6.1) hermes-parser: 0.25.1 zod: 4.3.6 zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color - eslint-plugin-react@7.37.5(eslint@10.0.0(jiti@2.6.1)): + eslint-plugin-react@7.37.5(eslint@9.39.3(jiti@2.6.1)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -7080,7 +7123,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.2 - eslint: 10.0.0(jiti@2.6.1) + eslint: 9.39.3(jiti@2.6.1) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -7089,7 +7132,7 @@ snapshots: object.fromentries: 2.0.8 object.values: 1.2.1 prop-types: 15.8.1 - resolve: 2.0.0-next.5 + resolve: 2.0.0-next.6 semver: 6.3.1 string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 @@ -7099,10 +7142,8 @@ snapshots: esrecurse: 4.3.0 estraverse: 4.3.0 - eslint-scope@9.1.0: + eslint-scope@8.4.0: dependencies: - '@types/esrecurse': 4.3.1 - '@types/estree': 1.0.8 esrecurse: 4.3.0 estraverse: 5.3.0 @@ -7110,27 +7151,30 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint-visitor-keys@5.0.0: {} + eslint-visitor-keys@5.0.1: {} - eslint@10.0.0(jiti@2.6.1): + eslint@9.39.3(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.1 - '@eslint/config-helpers': 0.5.2 - '@eslint/core': 1.1.0 - '@eslint/plugin-kit': 0.6.0 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.3 + '@eslint/js': 9.39.3 + '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - ajv: 6.12.6 + ajv: 6.14.0 + chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 escape-string-regexp: 4.0.0 - eslint-scope: 9.1.0 - eslint-visitor-keys: 5.0.0 - espree: 11.1.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 @@ -7141,7 +7185,8 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.0 + lodash.merge: 4.6.2 + minimatch: 3.1.2 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -7149,11 +7194,11 @@ snapshots: transitivePeerDependencies: - supports-color - espree@11.1.0: + espree@10.4.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) - eslint-visitor-keys: 5.0.0 + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 esprima@4.0.1: {} @@ -7341,9 +7386,9 @@ snapshots: forwarded@0.2.0: {} - framer-motion@12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + framer-motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - motion-dom: 12.34.0 + motion-dom: 12.34.3 motion-utils: 12.29.2 tslib: 2.8.1 optionalDependencies: @@ -7388,7 +7433,7 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.4.0: {} + get-east-asian-width@1.5.0: {} get-intrinsic@1.3.0: dependencies: @@ -7442,10 +7487,12 @@ snapshots: foreground-child: 3.3.1 jackspeak: 3.4.3 minimatch: 9.0.5 - minipass: 7.1.2 + minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + globals@14.0.0: {} + globals@16.4.0: {} globalthis@1.0.4: @@ -7489,7 +7536,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.11.9: {} + hono@4.12.1: {} http-errors@2.0.1: dependencies: @@ -7532,8 +7579,8 @@ snapshots: import-in-the-middle@2.0.6: dependencies: - acorn: 8.15.0 - acorn-import-attributes: 1.9.5(acorn@8.15.0) + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) cjs-module-lexer: 2.2.0 module-details-from-path: 1.0.4 @@ -7728,13 +7775,9 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jackspeak@4.2.3: - dependencies: - '@isaacs/cliui': 9.0.0 - jest-worker@27.5.1: dependencies: - '@types/node': 20.19.33 + '@types/node': 25.3.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -7804,54 +7847,54 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lightningcss-android-arm64@1.30.2: + lightningcss-android-arm64@1.31.1: optional: true - lightningcss-darwin-arm64@1.30.2: + lightningcss-darwin-arm64@1.31.1: optional: true - lightningcss-darwin-x64@1.30.2: + lightningcss-darwin-x64@1.31.1: optional: true - lightningcss-freebsd-x64@1.30.2: + lightningcss-freebsd-x64@1.31.1: optional: true - lightningcss-linux-arm-gnueabihf@1.30.2: + lightningcss-linux-arm-gnueabihf@1.31.1: optional: true - lightningcss-linux-arm64-gnu@1.30.2: + lightningcss-linux-arm64-gnu@1.31.1: optional: true - lightningcss-linux-arm64-musl@1.30.2: + lightningcss-linux-arm64-musl@1.31.1: optional: true - lightningcss-linux-x64-gnu@1.30.2: + lightningcss-linux-x64-gnu@1.31.1: optional: true - lightningcss-linux-x64-musl@1.30.2: + lightningcss-linux-x64-musl@1.31.1: optional: true - lightningcss-win32-arm64-msvc@1.30.2: + lightningcss-win32-arm64-msvc@1.31.1: optional: true - lightningcss-win32-x64-msvc@1.30.2: + lightningcss-win32-x64-msvc@1.31.1: optional: true - lightningcss@1.30.2: + lightningcss@1.31.1: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.30.2 - lightningcss-darwin-arm64: 1.30.2 - lightningcss-darwin-x64: 1.30.2 - lightningcss-freebsd-x64: 1.30.2 - lightningcss-linux-arm-gnueabihf: 1.30.2 - lightningcss-linux-arm64-gnu: 1.30.2 - lightningcss-linux-arm64-musl: 1.30.2 - lightningcss-linux-x64-gnu: 1.30.2 - lightningcss-linux-x64-musl: 1.30.2 - lightningcss-win32-arm64-msvc: 1.30.2 - lightningcss-win32-x64-msvc: 1.30.2 + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 lines-and-columns@1.2.4: {} @@ -7861,6 +7904,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.merge@4.6.2: {} + log-symbols@6.0.0: dependencies: chalk: 5.6.2 @@ -7886,7 +7931,7 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - marked@17.0.2: {} + marked@17.0.3: {} math-intrinsics@1.1.0: {} @@ -7919,7 +7964,7 @@ snapshots: mimic-function@5.0.1: {} - minimatch@10.2.0: + minimatch@10.2.2: dependencies: brace-expansion: 5.0.2 @@ -7933,19 +7978,19 @@ snapshots: minimist@1.2.8: {} - minipass@7.1.2: {} + minipass@7.1.3: {} module-details-from-path@1.0.4: {} - motion-dom@12.34.0: + motion-dom@12.34.3: dependencies: motion-utils: 12.29.2 motion-utils@12.29.2: {} - motion@12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - framer-motion: 12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + framer-motion: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tslib: 2.8.1 optionalDependencies: react: 19.2.4 @@ -7953,9 +7998,9 @@ snapshots: ms@2.1.3: {} - msw@2.12.10(@types/node@25.2.3)(typescript@5.9.3): + msw@2.12.10(@types/node@25.3.0)(typescript@5.9.3): dependencies: - '@inquirer/confirm': 5.1.21(@types/node@25.2.3) + '@inquirer/confirm': 5.1.21(@types/node@25.3.0) '@mswjs/interceptors': 0.41.3 '@open-draft/deferred-promise': 2.2.0 '@types/statuses': 2.0.6 @@ -7999,7 +8044,7 @@ snapshots: dependencies: '@next/env': 16.1.6 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.9.19 + baseline-browser-mapping: 2.10.0 caniuse-lite: 1.0.30001770 postcss: 8.4.31 react: 19.2.4 @@ -8023,6 +8068,13 @@ snapshots: node-domexception@1.0.0: {} + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -8184,7 +8236,7 @@ snapshots: path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 - minipass: 7.1.2 + minipass: 7.1.3 path-to-regexp@6.3.0: {} @@ -8241,25 +8293,25 @@ snapshots: dependencies: xtend: 4.0.2 - posthog-js@1.347.2: + posthog-js@1.352.0: dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/api-logs': 0.208.0 '@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) - '@posthog/core': 1.22.0 - '@posthog/types': 1.347.2 + '@posthog/core': 1.23.1 + '@posthog/types': 1.352.0 core-js: 3.48.0 dompurify: 3.3.1 fflate: 0.4.8 - preact: 10.28.3 + preact: 10.28.4 query-selector-shadow-dom: 1.0.1 web-vitals: 5.1.0 powershell-utils@0.1.0: {} - preact@10.28.3: {} + preact@10.28.4: {} prelude-ls@1.2.1: {} @@ -8294,7 +8346,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 25.2.3 + '@types/node': 25.3.0 long: 5.3.2 proxy-addr@2.0.7: @@ -8396,9 +8448,12 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - resolve@2.0.0-next.5: + resolve@2.0.0-next.6: dependencies: + es-errors: 1.3.0 is-core-module: 2.16.1 + node-exports-info: 1.6.0 + object-keys: 1.1.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -8411,35 +8466,35 @@ snapshots: reusify@1.1.0: {} - rollup@4.57.1: + rollup@4.58.0: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.57.1 - '@rollup/rollup-android-arm64': 4.57.1 - '@rollup/rollup-darwin-arm64': 4.57.1 - '@rollup/rollup-darwin-x64': 4.57.1 - '@rollup/rollup-freebsd-arm64': 4.57.1 - '@rollup/rollup-freebsd-x64': 4.57.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 - '@rollup/rollup-linux-arm-musleabihf': 4.57.1 - '@rollup/rollup-linux-arm64-gnu': 4.57.1 - '@rollup/rollup-linux-arm64-musl': 4.57.1 - '@rollup/rollup-linux-loong64-gnu': 4.57.1 - '@rollup/rollup-linux-loong64-musl': 4.57.1 - '@rollup/rollup-linux-ppc64-gnu': 4.57.1 - '@rollup/rollup-linux-ppc64-musl': 4.57.1 - '@rollup/rollup-linux-riscv64-gnu': 4.57.1 - '@rollup/rollup-linux-riscv64-musl': 4.57.1 - '@rollup/rollup-linux-s390x-gnu': 4.57.1 - '@rollup/rollup-linux-x64-gnu': 4.57.1 - '@rollup/rollup-linux-x64-musl': 4.57.1 - '@rollup/rollup-openbsd-x64': 4.57.1 - '@rollup/rollup-openharmony-arm64': 4.57.1 - '@rollup/rollup-win32-arm64-msvc': 4.57.1 - '@rollup/rollup-win32-ia32-msvc': 4.57.1 - '@rollup/rollup-win32-x64-gnu': 4.57.1 - '@rollup/rollup-win32-x64-msvc': 4.57.1 + '@rollup/rollup-android-arm-eabi': 4.58.0 + '@rollup/rollup-android-arm64': 4.58.0 + '@rollup/rollup-darwin-arm64': 4.58.0 + '@rollup/rollup-darwin-x64': 4.58.0 + '@rollup/rollup-freebsd-arm64': 4.58.0 + '@rollup/rollup-freebsd-x64': 4.58.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.58.0 + '@rollup/rollup-linux-arm-musleabihf': 4.58.0 + '@rollup/rollup-linux-arm64-gnu': 4.58.0 + '@rollup/rollup-linux-arm64-musl': 4.58.0 + '@rollup/rollup-linux-loong64-gnu': 4.58.0 + '@rollup/rollup-linux-loong64-musl': 4.58.0 + '@rollup/rollup-linux-ppc64-gnu': 4.58.0 + '@rollup/rollup-linux-ppc64-musl': 4.58.0 + '@rollup/rollup-linux-riscv64-gnu': 4.58.0 + '@rollup/rollup-linux-riscv64-musl': 4.58.0 + '@rollup/rollup-linux-s390x-gnu': 4.58.0 + '@rollup/rollup-linux-x64-gnu': 4.58.0 + '@rollup/rollup-linux-x64-musl': 4.58.0 + '@rollup/rollup-openbsd-x64': 4.58.0 + '@rollup/rollup-openharmony-arm64': 4.58.0 + '@rollup/rollup-win32-arm64-msvc': 4.58.0 + '@rollup/rollup-win32-ia32-msvc': 4.58.0 + '@rollup/rollup-win32-x64-gnu': 4.58.0 + '@rollup/rollup-win32-x64-msvc': 4.58.0 fsevents: 2.3.3 router@2.2.0: @@ -8549,7 +8604,7 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@3.8.5(@types/node@25.2.3)(typescript@5.9.3): + shadcn@3.8.5(@types/node@25.3.0)(typescript@5.9.3): dependencies: '@antfu/ni': 25.0.0 '@babel/core': 7.29.0 @@ -8571,7 +8626,7 @@ snapshots: fuzzysort: 3.1.0 https-proxy-agent: 7.0.6 kleur: 4.1.5 - msw: 2.12.10(@types/node@25.2.3)(typescript@5.9.3) + msw: 2.12.10(@types/node@25.3.0)(typescript@5.9.3) node-fetch: 3.3.2 open: 11.0.0 ora: 8.2.0 @@ -8580,7 +8635,7 @@ snapshots: prompts: 2.4.2 recast: 0.23.11 stringify-object: 5.0.0 - tailwind-merge: 3.4.1 + tailwind-merge: 3.5.0 ts-morph: 26.0.0 tsconfig-paths: 4.2.0 validate-npm-package-name: 7.0.2 @@ -8718,7 +8773,7 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.4.0 + get-east-asian-width: 1.5.0 strip-ansi: 7.1.2 string.prototype.includes@2.0.1: @@ -8791,6 +8846,8 @@ snapshots: strip-final-newline@4.0.0: {} + strip-json-comments@3.1.1: {} + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): dependencies: client-only: 0.0.1 @@ -8798,6 +8855,10 @@ snapshots: optionalDependencies: '@babel/core': 7.29.0 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + supports-color@8.1.1: dependencies: has-flag: 4.0.0 @@ -8825,9 +8886,9 @@ snapshots: tagged-tag@1.0.0: {} - tailwind-merge@3.4.1: {} + tailwind-merge@3.5.0: {} - tailwindcss@4.1.18: {} + tailwindcss@4.2.0: {} tapable@2.3.0: {} @@ -8843,7 +8904,7 @@ snapshots: terser@5.46.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 + acorn: 8.16.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -8951,13 +9012,13 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.0.0(jiti@2.6.1) + '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.3(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -8971,9 +9032,7 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - undici-types@6.21.0: {} - - undici-types@7.16.0: {} + undici-types@7.18.2: {} unicorn-magic@0.3.0: {} @@ -8983,7 +9042,7 @@ snapshots: unplugin@1.0.1: dependencies: - acorn: 8.15.0 + acorn: 8.16.0 chokidar: 3.6.0 webpack-sources: 3.3.4 webpack-virtual-modules: 0.5.0 @@ -9061,8 +9120,8 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - acorn-import-phases: 1.0.4(acorn@8.15.0) + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.1 chrome-trace-event: 1.0.4 enhanced-resolve: 5.19.0 @@ -9161,6 +9220,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.18.0: {} + wsl-utils@0.3.1: dependencies: is-wsl: 3.1.1 diff --git a/src/app/(dashboard)/dashboard/chat/page.tsx b/src/app/(dashboard)/dashboard/chat/page.tsx index 38c5045..46d344b 100644 --- a/src/app/(dashboard)/dashboard/chat/page.tsx +++ b/src/app/(dashboard)/dashboard/chat/page.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useChat } from "@ai-sdk/react"; -import { UserAvatar } from "@clerk/nextjs"; +import { UserAvatar, useAuth } from "@clerk/nextjs"; import type { Id } from "@convex/_generated/dataModel"; import type { UIMessage } from "ai"; import { useMutation, useQuery } from "convex/react"; @@ -10,6 +10,7 @@ import { api } from "@convex/_generated/api"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { marked } from "marked"; +import { hasOrgPermission, ORG_PERMISSIONS } from "@/lib/auth/rbac"; import { Card, CardContent, @@ -29,7 +30,7 @@ import { const SUGGESTED_PROMPTS = [ "Give me a launch checklist for a SaaS MVP this week.", - "Help me define Free vs Pro plan boundaries for B2B SaaS.", + "Help me define Free vs Organizations plan boundaries for B2B SaaS.", "Draft onboarding copy for a new user dashboard.", "What analytics events should this app track first?", ]; @@ -229,15 +230,33 @@ function MessageBubble({ } export default function ChatPage(): React.ReactElement { + const { has, orgRole, isLoaded } = useAuth(); + const canReadChat = + isLoaded && + hasOrgPermission({ + orgRole, + has, + permission: ORG_PERMISSIONS.CHAT_READ, + }); + const canCreateChat = + isLoaded && + hasOrgPermission({ + orgRole, + has, + permission: ORG_PERMISSIONS.CHAT_CREATE, + }); const [input, setInput] = useState(""); const [threadSearch, setThreadSearch] = useState(""); const [activeThreadId, setActiveThreadId] = useState | null>(null); const [isCreatingThread, setIsCreatingThread] = useState(false); - const threads = useQuery(api.chat.listUserChatThreads); + const threads = useQuery( + api.chat.listOrganizationChatThreads, + canReadChat ? {} : "skip", + ); const threadMessages = useQuery( api.chat.getThreadMessages, - activeThreadId ? { threadId: activeThreadId } : "skip", + canReadChat && activeThreadId ? { threadId: activeThreadId } : "skip", ); const saveChatMessage = useMutation(api.chat.saveUserChatMessage); const createChatThread = useMutation(api.chat.createChatThread); @@ -345,6 +364,11 @@ export default function ChatPage(): React.ReactElement { const handleCreateThread = useCallback(async (): Promise | null> => { + if (!canCreateChat) { + toast.error("You do not have permission to create chat threads."); + return null; + } + if (isCreatingThread) { return null; } @@ -367,7 +391,7 @@ export default function ChatPage(): React.ReactElement { } finally { setIsCreatingThread(false); } - }, [createChatThread, isCreatingThread, setMessages]); + }, [canCreateChat, createChatThread, isCreatingThread, setMessages]); return (
@@ -388,7 +412,7 @@ export default function ChatPage(): React.ReactElement { variant="outline" size="sm" onClick={() => void handleCreateThread()} - disabled={isCreatingThread} + disabled={isCreatingThread || !canCreateChat} > setThreadSearch(event.currentTarget.value)} + onChange={(event) => + setThreadSearch(event.currentTarget.value) + } placeholder="Search chats..." className="h-9" />
- {filteredThreads.length ? ( + {!isLoaded ? ( +
+ Loading workspace chats... +
+ ) : !canReadChat ? ( +
+ You do not have permission to view workspace chats. +
+ ) : filteredThreads.length ? ( filteredThreads.map((thread) => ( )} - + {canDeleteFiles ? ( + + ) : null} ); diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 11ea1a0..1558f32 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -22,7 +22,7 @@ import { export default function DashboardPage() { const { user, isLoaded } = useUser(); - const { isPro, plan, isLoading } = useUserPlan(); + const { isOrganizationsPlan, plan, isLoading } = useUserPlan(); if (!isLoaded || isLoading) { return ( @@ -70,16 +70,16 @@ export default function DashboardPage() {
{plan}
- - {isPro ? "Active" : "Limited"} + + {isOrganizationsPlan ? "Active" : "Limited"}

- {isPro - ? "Enjoying all Pro features" + {isOrganizationsPlan + ? "Enjoying all Organizations features" : "Upgrade to unlock more features"}

- {!isPro && ( + {!isOrganizationsPlan && (
@@ -132,7 +132,7 @@ export default function DashboardPage() { {/* Upgrade Prompt (for free users) */} - {!isPro && ( + {!isOrganizationsPlan && (
@@ -141,7 +141,7 @@ export default function DashboardPage() { strokeWidth={2} className="h-5 w-5" /> - Unlock Pro Features + Unlock Organizations Features
Get access to unlimited features and priority support diff --git a/src/app/(dashboard)/onboarding/page.tsx b/src/app/(dashboard)/onboarding/page.tsx index 61a744b..c9710b3 100644 --- a/src/app/(dashboard)/onboarding/page.tsx +++ b/src/app/(dashboard)/onboarding/page.tsx @@ -2,10 +2,18 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -import { UserAvatar, useUser } from "@clerk/nextjs"; +import { + CreateOrganization, + OrganizationList, + UserAvatar, + useAuth, + useUser, +} from "@clerk/nextjs"; +import { dark } from "@clerk/themes"; import { useMutation, useQuery } from "convex/react"; import { api } from "@convex/_generated/api"; import { toast } from "sonner"; +import { useTheme } from "next-themes"; import { Button } from "@/components/ui/button"; import { Card, @@ -16,6 +24,7 @@ import { } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Progress } from "@/components/ui/progress"; +import { Skeleton } from "@/components/ui/skeleton"; import { HugeiconsIcon } from "@hugeicons/react"; import { Tick02Icon, CheckmarkSquare02Icon } from "@hugeicons/core-free-icons"; import { Logo } from "@/components/logo"; @@ -47,9 +56,18 @@ const BUILDER_GOALS: { }, ]; +function toActiveStep(step: Step | string | null | undefined): ActiveStep { + if (step === "profile") return "profile"; + if (step === "preferences") return "preferences"; + if (step === "complete") return "preferences"; + return "welcome"; +} + export default function OnboardingPage() { const router = useRouter(); - const { user } = useUser(); + const { user, isLoaded: isUserLoaded } = useUser(); + const { orgId, isLoaded: isAuthLoaded } = useAuth(); + const { resolvedTheme } = useTheme(); const onboardingStatus = useQuery(api.users.getOnboardingStatus); const updateStep = useMutation(api.users.updateOnboardingStep); const completeOnboarding = useMutation(api.users.completeOnboarding); @@ -61,12 +79,30 @@ export default function OnboardingPage() { useEffect(() => { if (!onboardingStatus) return; - setCurrentStep((onboardingStatus.currentStep as Step) ?? "welcome"); - }, [onboardingStatus]); + if (onboardingStatus.completed) { + router.replace("/dashboard"); + return; + } - const currentStepIndex = ONBOARDING_STEPS.indexOf( - (currentStep === "complete" ? "preferences" : currentStep) as ActiveStep, - ); + const normalizedStep = + onboardingStatus.currentStep === "complete" + ? "preferences" + : toActiveStep(onboardingStatus.currentStep); + setCurrentStep(normalizedStep); + }, [onboardingStatus, router]); + + useEffect(() => { + if (!onboardingStatus) return; + if (onboardingStatus.completed) return; + if (onboardingStatus.currentStep !== "complete") return; + + // Heal stale onboarding state so future visits remain actionable. + void updateStep({ step: "preferences" }).catch((error) => { + console.warn("Failed to normalize onboarding step", error); + }); + }, [onboardingStatus, updateStep]); + + const currentStepIndex = ONBOARDING_STEPS.indexOf(toActiveStep(currentStep)); const progress = ((currentStepIndex + 1) / ONBOARDING_STEPS.length) * 100; async function handleNext() { @@ -136,7 +172,7 @@ export default function OnboardingPage() { return ( -
+
@@ -146,10 +182,10 @@ export default function OnboardingPage() {
- -
+ +
-
+
Set up your profile
-
+
Customize your preferences
-
+
-
+
-
- +
+
You're All Set @@ -317,14 +349,97 @@ export default function OnboardingPage() { } } - if (!user || !onboardingStatus) { + function renderOnboardingSkeleton() { return ( -
-
+
+
+
+
+ + +
+ +
+ + + + +
+ + +
+
+ + + + + +
+ +
+ + +
+
+
+ ); + } + + if (!isUserLoaded || !isAuthLoaded || !user) { + return renderOnboardingSkeleton(); + } + + if (!orgId) { + return ( +
+
+
+ + + Select a workspace + + Choose an existing organization to continue onboarding. + + + + + + + + + + Create a workspace + + Create a new organization if your workspace does not exist + yet. + + + + + + +
+
); } + if (!onboardingStatus) { + return renderOnboardingSkeleton(); + } + return (
diff --git a/src/app/(marketing)/pricing/page.tsx b/src/app/(marketing)/pricing/page.tsx index b0ce5ea..a6072f1 100644 --- a/src/app/(marketing)/pricing/page.tsx +++ b/src/app/(marketing)/pricing/page.tsx @@ -19,9 +19,9 @@ const pricingPlans = [ description: "Core features to get started, no commitment required.", }, { - name: "Pro", + name: "Organizations", price: "12", - description: "Advanced tools, priority support, built for serious growth.", + description: "Organization-level billing and governance for serious growth.", }, ]; diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index a2ceac6..fbd4029 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -3,24 +3,46 @@ import { convertToModelMessages, stepCountIs, streamText } from "ai"; import type { UIMessage } from "ai"; import { NextResponse } from "next/server"; import { rateLimit } from "@/lib/rate-limit"; -import { chatConfig } from "@/lib/ai/chat-config"; +import { chatConfig, getChatPlanLimitsForPlan } from "@/lib/ai/chat-config"; import { resolveChatToolNames, resolveChatTools, } from "@/lib/ai/tools/registry"; +import { + hasOrgPermission, + ORG_BILLING_PLANS, + ORG_PERMISSIONS, + resolveOrganizationBillingPlanFromHas, +} from "@/lib/auth/rbac"; +import { hasTrustedOrigin } from "@/lib/security/request"; export const maxDuration = 30; const enabledToolNames = resolveChatToolNames(chatConfig.enabledTools); const tools = resolveChatTools(enabledToolNames); -const CHAT_USAGE_METADATA_KEY = "chatMessagesSent"; -const CHAT_LIMIT_METADATA_KEY = "chatMessageLimit"; -const CHAT_LAST_MESSAGE_AT_KEY = "chatLastMessageAt"; -const CHAT_FIRST_MESSAGE_AT_KEY = "chatFirstMessageAt"; +const CHAT_USAGE_METADATA_KEY = "chatMessagesSentByOrg"; +const CHAT_LIMIT_METADATA_KEY = "chatMessageLimitByOrg"; +const CHAT_LAST_MESSAGE_AT_KEY = "chatLastMessageAtByOrg"; +const CHAT_FIRST_MESSAGE_AT_KEY = "chatFirstMessageAtByOrg"; + +const limiters = new Map>(); -const limiter = rateLimit({ - interval: chatConfig.rateLimit.intervalMs, - limit: chatConfig.rateLimit.maxRequests, -}); +function getPlanLimiter(params: { + intervalMs: number; + maxRequests: number; +}): ReturnType { + const key = `${params.intervalMs}:${params.maxRequests}`; + const existing = limiters.get(key); + if (existing) { + return existing; + } + + const created = rateLimit({ + interval: params.intervalMs, + limit: params.maxRequests, + }); + limiters.set(key, created); + return created; +} function isValidBody(body: unknown): body is { messages: UIMessage[] } { if (typeof body !== "object" || body === null) return false; @@ -28,21 +50,43 @@ function isValidBody(body: unknown): body is { messages: UIMessage[] } { return Array.isArray(payload.messages); } -function getUserMessageCount(privateMetadata: unknown): number { - if (typeof privateMetadata !== "object" || privateMetadata === null) return 0; +function getPerOrgMetadataMap( + privateMetadata: unknown, + key: string, +): Record { + if (typeof privateMetadata !== "object" || privateMetadata === null) return {}; const metadata = privateMetadata as Record; - const value = metadata[CHAT_USAGE_METADATA_KEY]; + const value = metadata[key]; + if (typeof value !== "object" || value === null) { + return {}; + } + + return value as Record; +} + +function getUserMessageCount(params: { + privateMetadata: unknown; + orgId: string; +}): number { + const map = getPerOrgMetadataMap(params.privateMetadata, CHAT_USAGE_METADATA_KEY); + const value = map[params.orgId]; return typeof value === "number" && Number.isFinite(value) ? value : 0; } -async function claimLifetimeChatMessage(userId: string): Promise<{ +async function claimLifetimeChatMessage( + userId: string, + orgId: string, + limit: number, +): Promise<{ allowed: boolean; remaining: number; }> { - const limit = chatConfig.lifetimeMessageLimit.maxMessages; const client = await clerkClient(); const user = await client.users.getUser(userId); - const usedCount = getUserMessageCount(user.privateMetadata); + const usedCount = getUserMessageCount({ + privateMetadata: user.privateMetadata, + orgId, + }); if (usedCount >= limit) { return { allowed: false, remaining: 0 }; @@ -53,15 +97,36 @@ async function claimLifetimeChatMessage(userId: string): Promise<{ string, unknown >; + const usageByOrg = getPerOrgMetadataMap(privateMetadata, CHAT_USAGE_METADATA_KEY); + const limitByOrg = getPerOrgMetadataMap(privateMetadata, CHAT_LIMIT_METADATA_KEY); + const firstMessageByOrg = getPerOrgMetadataMap( + privateMetadata, + CHAT_FIRST_MESSAGE_AT_KEY, + ); + const lastMessageByOrg = getPerOrgMetadataMap( + privateMetadata, + CHAT_LAST_MESSAGE_AT_KEY, + ); await client.users.updateUserMetadata(userId, { privateMetadata: { ...privateMetadata, - [CHAT_USAGE_METADATA_KEY]: usedCount + 1, - [CHAT_LIMIT_METADATA_KEY]: limit, - [CHAT_FIRST_MESSAGE_AT_KEY]: - privateMetadata[CHAT_FIRST_MESSAGE_AT_KEY] ?? nowIso, - [CHAT_LAST_MESSAGE_AT_KEY]: nowIso, + [CHAT_USAGE_METADATA_KEY]: { + ...usageByOrg, + [orgId]: usedCount + 1, + }, + [CHAT_LIMIT_METADATA_KEY]: { + ...limitByOrg, + [orgId]: limit, + }, + [CHAT_FIRST_MESSAGE_AT_KEY]: { + ...firstMessageByOrg, + [orgId]: firstMessageByOrg[orgId] ?? nowIso, + }, + [CHAT_LAST_MESSAGE_AT_KEY]: { + ...lastMessageByOrg, + [orgId]: nowIso, + }, }, }); @@ -76,21 +141,54 @@ function hasAtLeastOneUserMessage(messages: UIMessage[]): boolean { } export async function POST(req: Request): Promise { - const { userId } = await auth(); + if (!hasTrustedOrigin(req)) { + return NextResponse.json({ error: "Invalid request origin." }, { status: 403 }); + } + + const { userId, orgId, orgRole, has } = await auth(); if (!userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + if (!orgId) { + return NextResponse.json( + { error: "Active organization required." }, + { status: 403 }, + ); + } + if ( + !hasOrgPermission({ + orgRole, + has, + permission: ORG_PERMISSIONS.CHAT_CREATE, + }) + ) { + return NextResponse.json( + { error: "Forbidden: missing chat permission." }, + { status: 403 }, + ); + } + + const organizationPlan = resolveOrganizationBillingPlanFromHas({ + orgId, + has, + }); + const chatPlanLimits = getChatPlanLimitsForPlan(organizationPlan); + const limiter = getPlanLimiter({ + intervalMs: chatPlanLimits.rateLimit.intervalMs, + maxRequests: chatPlanLimits.rateLimit.maxRequests, + }); const forwardedFor = req.headers.get("x-forwarded-for") ?? "unknown"; const ip = forwardedFor.split(",")[0]?.trim() || "unknown"; - const { success, remaining, reset } = limiter.check(`${userId}:${ip}`); + const { success, remaining, reset } = limiter.check(`${userId}:${orgId}:${ip}`); const rateLimitHeaders = { "X-RateLimit-Remaining": String(remaining), "X-RateLimit-Reset": String(reset), "X-AI-Model": chatConfig.model, "X-AI-Tools": enabledToolNames.join(",") || "none", + "X-AI-Organization-Plan": organizationPlan, "X-AI-User-Message-Limit": chatConfig.lifetimeMessageLimit.enabled - ? String(chatConfig.lifetimeMessageLimit.maxMessages) + ? String(chatPlanLimits.lifetimeMessageLimit.maxMessages) : "disabled", }; @@ -143,14 +241,22 @@ export async function POST(req: Request): Promise { let remainingMessagesHeader = "unlimited"; if (chatConfig.lifetimeMessageLimit.enabled) { - const messageAllowance = await claimLifetimeChatMessage(userId); + const messageAllowance = await claimLifetimeChatMessage( + userId, + orgId, + chatPlanLimits.lifetimeMessageLimit.maxMessages, + ); if (!messageAllowance.allowed) { - const lifetimeLimit = chatConfig.lifetimeMessageLimit.maxMessages; + const lifetimeLimit = chatPlanLimits.lifetimeMessageLimit.maxMessages; const lifetimeLabel = `${lifetimeLimit} lifetime message${lifetimeLimit === 1 ? "" : "s"}`; + const planLabel = + organizationPlan === ORG_BILLING_PLANS.ORGANIZATIONS + ? "organizations" + : "free"; return NextResponse.json( { code: "MESSAGE_LIMIT_REACHED", - error: `Message limit reached. This boilerplate allows only ${lifetimeLabel} per account.`, + error: `Message limit reached. The ${planLabel} plan allows only ${lifetimeLabel} per account.`, }, { status: 403, diff --git a/src/app/api/email/route.ts b/src/app/api/email/route.ts index 14859f3..ad2c2da 100644 --- a/src/app/api/email/route.ts +++ b/src/app/api/email/route.ts @@ -2,6 +2,9 @@ import { NextResponse } from "next/server"; import { auth, currentUser } from "@clerk/nextjs/server"; import { rateLimit } from "@/lib/rate-limit"; import { sendEmail, welcomeEmail, planChangedEmail } from "@/lib/emails"; +import { ORG_BILLING_PLANS } from "@/lib/auth/rbac"; +import { normalizeEmailToken } from "@/lib/emails/escape"; +import { hasTrustedOrigin } from "@/lib/security/request"; const limiter = rateLimit({ interval: 60_000, limit: 10 }); @@ -21,23 +24,34 @@ interface PlanChangedPayload { type EmailPayload = WelcomePayload | PlanChangedPayload; +function parsePlanValue(value: string): "free" | "organizations" | null { + const normalized = value.trim().toLowerCase(); + if (normalized === ORG_BILLING_PLANS.FREE) { + return ORG_BILLING_PLANS.FREE; + } + if (normalized === ORG_BILLING_PLANS.ORGANIZATIONS) { + return ORG_BILLING_PLANS.ORGANIZATIONS; + } + return null; +} + function isValidPayload(body: unknown): body is EmailPayload { if (typeof body !== "object" || body === null) return false; const payload = body as Record; if (payload.template === "welcome") { - return typeof payload.name === "string" && payload.name.length > 0; + return typeof payload.name === "string" && payload.name.trim().length > 0; } if (payload.template === "plan-changed") { return ( typeof payload.name === "string" && - payload.name.length > 0 && + payload.name.trim().length > 0 && typeof payload.previousPlan === "string" && - payload.previousPlan.length > 0 && + payload.previousPlan.trim().length > 0 && typeof payload.newPlan === "string" && - payload.newPlan.length > 0 + payload.newPlan.trim().length > 0 ); } @@ -74,6 +88,10 @@ const SUPPORTED_TEMPLATES: EmailTemplate[] = ["welcome", "plan-changed"]; * - { template: "plan-changed", name: string, previousPlan: string, newPlan: string } */ export async function POST(req: Request): Promise { + if (!hasTrustedOrigin(req)) { + return NextResponse.json({ error: "Invalid request origin" }, { status: 403 }); + } + const ip = req.headers.get("x-forwarded-for") ?? "unknown"; const { success: allowed, remaining, reset } = limiter.check(ip); @@ -95,7 +113,7 @@ export async function POST(req: Request): Promise { ); } - const { userId } = await auth(); + const { userId, orgId } = await auth(); if (!userId) { return NextResponse.json( @@ -103,6 +121,12 @@ export async function POST(req: Request): Promise { { status: 401, headers: rateLimitHeaders }, ); } + if (!orgId) { + return NextResponse.json( + { error: "Active organization required" }, + { status: 403, headers: rateLimitHeaders }, + ); + } const user = await currentUser(); @@ -137,7 +161,47 @@ export async function POST(req: Request): Promise { ); } - const { subject, html } = buildEmail(body); + const authenticatedDisplayName = normalizeEmailToken( + user.fullName ?? user.firstName ?? body.name, + 80, + ); + + if (!authenticatedDisplayName) { + return NextResponse.json( + { error: "Could not resolve display name for email payload" }, + { status: 400, headers: rateLimitHeaders }, + ); + } + + let emailPayload: EmailPayload; + if (body.template === "welcome") { + emailPayload = { + template: "welcome", + name: authenticatedDisplayName, + }; + } else { + const previousPlan = parsePlanValue(body.previousPlan); + const newPlan = parsePlanValue(body.newPlan); + + if (!previousPlan || !newPlan) { + return NextResponse.json( + { + error: + "Invalid plan values. Supported plans: free, organizations.", + }, + { status: 400, headers: rateLimitHeaders }, + ); + } + + emailPayload = { + template: "plan-changed", + name: authenticatedDisplayName, + previousPlan, + newPlan, + }; + } + + const { subject, html } = buildEmail(emailPayload); const result = await sendEmail({ to: user.primaryEmailAddress.emailAddress, subject, diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 016299c..10c7f6c 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -1,7 +1,9 @@ "use client"; import * as React from "react"; - +import { OrganizationSwitcher } from "@clerk/nextjs"; +import { dark } from "@clerk/themes"; +import { useTheme } from "next-themes"; import { NavMain } from "@/components/nav-main"; import { NavSecondary } from "@/components/nav-secondary"; import { NavUser } from "@/components/nav-user"; @@ -11,17 +13,15 @@ import { SidebarFooter, SidebarHeader, SidebarMenu, - SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar"; import { HugeiconsIcon } from "@hugeicons/react"; import { + ChatEdit01Icon, DashboardSquare01Icon, - User03Icon, FileAttachmentIcon, - ChatEdit01Icon, + User03Icon, } from "@hugeicons/core-free-icons"; -import { Logo } from "@/components/logo"; const data = { navMain: [ @@ -51,6 +51,8 @@ const data = { }; export function AppSidebar({ ...props }: React.ComponentProps) { + const { resolvedTheme } = useTheme(); + return ( ) { - }> -
- -
-
- Shipr - - Clone. Build. Ship. - -
-
+
diff --git a/src/components/billing/upgrade-button.tsx b/src/components/billing/upgrade-button.tsx index 0b15d9f..27745f5 100644 --- a/src/components/billing/upgrade-button.tsx +++ b/src/components/billing/upgrade-button.tsx @@ -8,9 +8,9 @@ import { useUserPlan } from "@/hooks/use-user-plan"; import Link from "next/link"; export function UpgradeButton() { - const { isPro, plan } = useUserPlan(); + const { isOrganizationsPlan, plan } = useUserPlan(); - if (isPro) return null; + if (isOrganizationsPlan) return null; const handleUpgradeClick = () => { posthog.capture("upgrade_button_clicked", { @@ -28,7 +28,7 @@ export function UpgradeButton() { onClick={handleUpgradeClick} > - Upgrade to Pro + Upgrade to Organizations ); } diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index d779ce4..0685f61 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -18,6 +18,7 @@ import { BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { usePathname } from "next/navigation"; +import { useAuth } from "@clerk/nextjs"; interface BreadcrumbData { parent: string | null; @@ -55,8 +56,11 @@ export function DashboardShell({ }: { children: React.ReactNode; }): React.ReactElement { - useSyncUser(); - useOnboarding(); + const { isLoaded, orgId } = useAuth(); + const hasOrganizationContext = isLoaded && Boolean(orgId); + + useSyncUser(hasOrganizationContext); + useOnboarding(hasOrganizationContext); const pathname = usePathname(); const breadcrumbs = getBreadcrumbsFromPathname(pathname); diff --git a/src/components/dashboard/file-upload.tsx b/src/components/dashboard/file-upload.tsx index 722f96c..747eb2e 100644 --- a/src/components/dashboard/file-upload.tsx +++ b/src/components/dashboard/file-upload.tsx @@ -4,8 +4,8 @@ import { useCallback, useRef, useState } from "react"; import { useFileUpload } from "@/hooks/use-file-upload"; import { ACCEPTED_FILE_EXTENSIONS, - FILE_STORAGE_LIMITS, formatFileSize, + getFileStorageLimitsForPlan, } from "@/lib/files/config"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -14,9 +14,10 @@ import { CloudUploadIcon, File01Icon, Cancel01Icon, - Loading03Icon, } from "@hugeicons/core-free-icons"; import { toast } from "sonner"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useUserPlan } from "@/hooks/use-user-plan"; interface FileUploadProps { /** Called after a successful upload */ @@ -28,8 +29,17 @@ export function FileUpload({ onUploadComplete }: FileUploadProps) { const [selectedFiles, setSelectedFiles] = useState([]); const fileInputRef = useRef(null); - const { upload, uploadMultiple, isUploading, error, progress, reset } = - useFileUpload(); + const { plan } = useUserPlan(); + const planLimits = getFileStorageLimitsForPlan(plan); + + const { upload, uploadMultiple, isUploading, progress, reset } = useFileUpload( + { + maxSize: planLimits.maxFileSizeBytes, + onError: (message) => { + toast.error(message); + }, + }, + ); const notifyUploadOutcome = useCallback( (results: (string | null)[]): void => { @@ -37,7 +47,6 @@ export function FileUpload({ onUploadComplete }: FileUploadProps) { const failedCount = results.length - successCount; if (successCount === 0) { - toast.error("Upload failed"); return; } @@ -130,7 +139,7 @@ export function FileUpload({ onUploadComplete }: FileUploadProps) { [reset], ); - const maxFileSizeLabel = formatFileSize(FILE_STORAGE_LIMITS.maxFileSizeBytes); + const maxFileSizeLabel = formatFileSize(planLimits.maxFileSizeBytes); const progressLabel: Record = { idle: "", @@ -156,11 +165,12 @@ export function FileUpload({ onUploadComplete }: FileUploadProps) { > {isUploading ? ( - +
+ + + + +
) : ( - {/* Error message */} - {error && ( -
- {error} + {isUploading ? ( +
+ {Array.from({ length: Math.max(selectedFiles.length, 1) }).map( + (_, index) => ( +
+ +
+ + +
+ +
+ ), + )}
- )} + ) : null} {/* Selected files preview */} {selectedFiles.length > 0 && !isUploading && ( diff --git a/src/components/dashboard/sidebar.tsx b/src/components/dashboard/sidebar.tsx index 75ae93c..94b46d3 100644 --- a/src/components/dashboard/sidebar.tsx +++ b/src/components/dashboard/sidebar.tsx @@ -34,7 +34,7 @@ const navItems = [ function SidebarContent() { const pathname = usePathname(); - const { plan, isPro } = useUserPlan(); + const { plan, isOrganizationsPlan } = useUserPlan(); const { user } = useUser(); return ( @@ -81,7 +81,7 @@ function SidebarContent() { {/* Upgrade CTA (if free plan) */} - {!isPro && ( + {!isOrganizationsPlan && (
@@ -90,7 +90,9 @@ function SidebarContent() { strokeWidth={2} className="h-4 w-4 text-primary" /> - Upgrade to Pro + + Upgrade to Organizations +

Unlock unlimited features and priority support @@ -116,7 +118,7 @@ function SidebarContent() {

{plan} diff --git a/src/components/posthog-identify.tsx b/src/components/posthog-identify.tsx index d5c63f2..25b2416 100644 --- a/src/components/posthog-identify.tsx +++ b/src/components/posthog-identify.tsx @@ -14,7 +14,7 @@ export function PostHogIdentify() { if (isSignedIn && user && !identifiedRef.current) { // Identify user in PostHog - const plan = has?.({ plan: "pro" }) ? "pro" : "free"; + const plan = has?.({ plan: "organizations" }) ? "organizations" : "free"; posthog.identify(user.id, { email: user.primaryEmailAddress?.emailAddress, name: user.fullName, diff --git a/src/components/pricing-3.tsx b/src/components/pricing-3.tsx index 93a0b5b..12098ea 100644 --- a/src/components/pricing-3.tsx +++ b/src/components/pricing-3.tsx @@ -25,17 +25,18 @@ const plans = [ buttonText: "Start Free", }, { - name: "Pro", + name: "Organizations", price: "$12", period: "/ month", originalPrice: "$15", badge: "Save $36 per year", - description: "For teams scaling with higher usage, speed, and support.", + description: "For teams scaling with org-level billing and governance.", features: [ + "Organization-scoped billing", + "Workspace governance controls", "Higher usage limits", "Faster processing", "Priority support", - "Early access to new features", ], buttonText: "Start 7-day free trial", highlighted: true, diff --git a/src/components/pricing-table.tsx b/src/components/pricing-table.tsx index cc91ee3..c484184 100644 --- a/src/components/pricing-table.tsx +++ b/src/components/pricing-table.tsx @@ -9,6 +9,7 @@ export function PricingTableClient(): React.ReactElement { return ( (ALLOWED_FILE_MIME_TYPES); +const ORG_REQUIRED_MESSAGE = + "Select or create an organization in onboarding before uploading files."; interface UploadState { isUploading: boolean; @@ -68,10 +71,28 @@ export function useFileUpload( error: null, progress: "idle", }); + const { isLoaded: isAuthLoaded, orgId } = useAuth(); const generateUploadUrl = useMutation(api.files.generateUploadUrl); const saveFile = useMutation(api.files.saveFile); + const normalizeUploadError = useCallback((error: unknown): string => { + const rawMessage = + error instanceof Error ? error.message : "Upload failed unexpectedly"; + + if (rawMessage.includes("Forbidden: active organization required")) { + return ORG_REQUIRED_MESSAGE; + } + + const uncaughtMatch = rawMessage.match(/Uncaught Error:\s*([^\n]+)/); + if (uncaughtMatch?.[1]) { + return uncaughtMatch[1].trim(); + } + + const normalized = rawMessage.replace(/\[CONVEX[^\]]+\]\s*/g, "").trim(); + return normalized || "Upload failed unexpectedly"; + }, []); + const validateFile = useCallback( (file: File): string | null => { if (!file) { @@ -110,6 +131,27 @@ export function useFileUpload( return null; } + if (isAuthLoaded && !orgId) { + setState({ + isUploading: false, + error: ORG_REQUIRED_MESSAGE, + progress: "idle", + }); + onError?.(ORG_REQUIRED_MESSAGE); + return null; + } + + if (!isAuthLoaded) { + const loadingMessage = "Authentication is still loading. Please try again."; + setState({ + isUploading: false, + error: loadingMessage, + progress: "idle", + }); + onError?.(loadingMessage); + return null; + } + setState({ isUploading: true, error: null, progress: "generating-url" }); try { @@ -148,14 +190,22 @@ export function useFileUpload( return storageId; } catch (err) { - const message = - err instanceof Error ? err.message : "Upload failed unexpectedly"; + const message = normalizeUploadError(err); setState({ isUploading: false, error: message, progress: "idle" }); onError?.(message); return null; } }, - [generateUploadUrl, saveFile, validateFile, onSuccess, onError], + [ + generateUploadUrl, + saveFile, + validateFile, + onSuccess, + onError, + isAuthLoaded, + orgId, + normalizeUploadError, + ], ); const uploadMultiple = useCallback( diff --git a/src/hooks/use-onboarding.ts b/src/hooks/use-onboarding.ts index 2566ac2..5b3ad0b 100644 --- a/src/hooks/use-onboarding.ts +++ b/src/hooks/use-onboarding.ts @@ -8,16 +8,17 @@ import { api } from "@convex/_generated/api"; /** * Hook to check if the user has completed onboarding. * Redirects to /onboarding if not completed and not already there. - * - * @returns Onboarding status and completion state. */ -export function useOnboarding() { +export function useOnboarding(enabled = true) { const router = useRouter(); const pathname = usePathname(); - const onboardingStatus = useQuery(api.users.getOnboardingStatus); + const onboardingStatus = useQuery( + api.users.getOnboardingStatus, + enabled ? {} : "skip", + ); useEffect(() => { - if (!onboardingStatus) return; + if (!enabled || !onboardingStatus) return; const isOnOnboardingPage = pathname === "/onboarding"; const shouldRedirectToOnboarding = @@ -30,11 +31,11 @@ export function useOnboarding() { } else if (shouldRedirectToDashboard) { router.push("/dashboard"); } - }, [onboardingStatus, pathname, router]); + }, [onboardingStatus, pathname, router, enabled]); return { completed: onboardingStatus?.completed ?? false, currentStep: onboardingStatus?.currentStep ?? "welcome", - isLoading: onboardingStatus === undefined, + isLoading: enabled && onboardingStatus === undefined, }; } diff --git a/src/hooks/use-sync-user.ts b/src/hooks/use-sync-user.ts index 1776ef0..1aa0110 100644 --- a/src/hooks/use-sync-user.ts +++ b/src/hooks/use-sync-user.ts @@ -4,6 +4,10 @@ import { useAuth, useUser } from "@clerk/nextjs"; import { useMutation, useQuery } from "convex/react"; import { api } from "@convex/_generated/api"; import { useEffect } from "react"; +import { + resolveOrganizationBillingPlanFromHas, + ORG_BILLING_PLANS, +} from "@/lib/auth/rbac"; /** * Syncs the authenticated Clerk user to the Convex `users` table. @@ -11,25 +15,26 @@ import { useEffect } from "react"; * Watches Clerk session data and writes changes to Convex whenever * the user's email, name, avatar, or billing plan drifts from what's * stored. Runs once on mount and re-syncs on any dependency change. - * - * @returns The Clerk `user` object, the matching Convex document (`convexUser`), - * and an `isLoaded` flag that is `false` until Clerk finishes loading. */ -export function useSyncUser() { - const { user, isLoaded } = useUser(); - const { has } = useAuth(); - const plan = - isLoaded && has ? (has({ plan: "pro" }) ? "pro" : "free") : undefined; +export function useSyncUser(enabled = true) { + const { user, isLoaded: isUserLoaded } = useUser(); + const { has, orgId, userId, isLoaded: isAuthLoaded } = useAuth(); + const plan = isAuthLoaded + ? resolveOrganizationBillingPlanFromHas({ + orgId, + has, + }) + : ORG_BILLING_PLANS.FREE; + const createOrUpdateUser = useMutation(api.users.createOrUpdateUser); - const existingUser = useQuery( - api.users.getUserByClerkId, - user?.id ? { clerkId: user.id } : "skip", - ); + const existingUser = useQuery(api.users.getCurrentUser); useEffect(() => { - if (!isLoaded || !user) return; + if (!enabled || !isUserLoaded || !isAuthLoaded || !user || !userId) { + return; + } - // Only sync if user doesn't exist or data changed + // Only sync if user doesn't exist or data changed. if ( !existingUser || existingUser.email !== user.primaryEmailAddress?.emailAddress || @@ -37,15 +42,26 @@ export function useSyncUser() { existingUser.imageUrl !== user.imageUrl || existingUser.plan !== plan ) { - createOrUpdateUser({ - clerkId: user.id, + void createOrUpdateUser({ email: user.primaryEmailAddress?.emailAddress ?? "", name: user.fullName ?? undefined, imageUrl: user.imageUrl ?? undefined, plan, + }).catch((error) => { + // Background sync should not crash the UI; auth can still be settling. + console.warn("Failed to sync Clerk user to Convex", error); }); } - }, [user, isLoaded, plan, existingUser, createOrUpdateUser]); + }, [ + user, + isUserLoaded, + isAuthLoaded, + userId, + plan, + existingUser, + createOrUpdateUser, + enabled, + ]); - return { user, convexUser: existingUser, isLoaded }; + return { user, convexUser: existingUser, isLoaded: isUserLoaded }; } diff --git a/src/hooks/use-user-plan.ts b/src/hooks/use-user-plan.ts index 13fb008..4f04dca 100644 --- a/src/hooks/use-user-plan.ts +++ b/src/hooks/use-user-plan.ts @@ -1,42 +1,45 @@ "use client"; import { useAuth } from "@clerk/nextjs"; +import { + resolveOrganizationBillingPlanFromHas, + type OrganizationBillingPlan, + ORG_BILLING_PLANS, +} from "@/lib/auth/rbac"; -/** Possible billing plans for a user. */ -export type Plan = "free" | "pro"; +/** Possible billing plans for an active organization. */ +export type Plan = OrganizationBillingPlan; /** - * Returns the current user's billing plan derived from Clerk session claims. + * Returns the active organization's billing plan derived from Clerk session claims. * - * Checks `has({ plan: "pro" })` via Clerk's `useAuth` - no extra API calls needed. - * - * @returns Object with `plan`, `isLoading`, `isPro`, and `isFree` flags. - * - * @example - * ```tsx - * const { isPro, isLoading } = useUserPlan(); - * - * if (isLoading) return ; - * if (isPro) return ; - * return ; - * ``` + * In this multi-tenant branch, plan gating is organization-scoped. */ export function useUserPlan(): { plan: Plan; isLoading: boolean; - isPro: boolean; + isOrganizationsPlan: boolean; isFree: boolean; + hasActiveOrganization: boolean; } { - const { has, isLoaded } = useAuth(); + const { has, isLoaded, orgId } = useAuth(); + + const hasActiveOrganization = Boolean(orgId); + const plan = isLoaded + ? resolveOrganizationBillingPlanFromHas({ + orgId, + has, + }) + : ORG_BILLING_PLANS.FREE; + const isOrganizationsPlan = plan === ORG_BILLING_PLANS.ORGANIZATIONS; - const isPro = isLoaded ? (has?.({ plan: "pro" }) ?? false) : false; - const isFree = !isPro; - const plan: Plan = isPro ? "pro" : "free"; + const isFree = !isOrganizationsPlan; return { plan, isLoading: !isLoaded, - isPro, + isOrganizationsPlan, isFree, + hasActiveOrganization, }; } diff --git a/src/lib/ai/chat-config.ts b/src/lib/ai/chat-config.ts index 752665e..7d8be64 100644 --- a/src/lib/ai/chat-config.ts +++ b/src/lib/ai/chat-config.ts @@ -1,4 +1,8 @@ import "server-only"; +import { + ORG_BILLING_PLANS, + type OrganizationBillingPlan, +} from "@/lib/auth/rbac"; const DEFAULT_CHAT_MODEL = "openai/gpt-4.1-mini"; const DEFAULT_SYSTEM_PROMPT = @@ -60,3 +64,55 @@ export const chatConfig = { ), }, } as const; + +const FREE_CHAT_PLAN_LIMITS = { + rateLimit: { + intervalMs: readPositiveIntEnv( + "AI_CHAT_RATE_LIMIT_WINDOW_MS_FREE", + chatConfig.rateLimit.intervalMs, + ), + maxRequests: readPositiveIntEnv( + "AI_CHAT_RATE_LIMIT_MAX_REQUESTS_FREE", + chatConfig.rateLimit.maxRequests, + ), + }, + lifetimeMessageLimit: { + maxMessages: readPositiveIntEnv( + "AI_CHAT_LIFETIME_MESSAGE_LIMIT_FREE", + chatConfig.lifetimeMessageLimit.maxMessages, + ), + }, +}; + +const ORGANIZATIONS_CHAT_PLAN_LIMITS = { + rateLimit: { + intervalMs: readPositiveIntEnv( + "AI_CHAT_RATE_LIMIT_WINDOW_MS_ORGANIZATIONS", + FREE_CHAT_PLAN_LIMITS.rateLimit.intervalMs, + ), + maxRequests: readPositiveIntEnv( + "AI_CHAT_RATE_LIMIT_MAX_REQUESTS_ORGANIZATIONS", + FREE_CHAT_PLAN_LIMITS.rateLimit.maxRequests, + ), + }, + lifetimeMessageLimit: { + maxMessages: readPositiveIntEnv( + "AI_CHAT_LIFETIME_MESSAGE_LIMIT_ORGANIZATIONS", + FREE_CHAT_PLAN_LIMITS.lifetimeMessageLimit.maxMessages, + ), + }, +}; + +export function getChatPlanLimitsForPlan(plan: OrganizationBillingPlan): { + rateLimit: { + intervalMs: number; + maxRequests: number; + }; + lifetimeMessageLimit: { + maxMessages: number; + }; +} { + return plan === ORG_BILLING_PLANS.ORGANIZATIONS + ? ORGANIZATIONS_CHAT_PLAN_LIMITS + : FREE_CHAT_PLAN_LIMITS; +} diff --git a/src/lib/ai/chat-history-config.ts b/src/lib/ai/chat-history-config.ts index 8aed7e2..2c7f35c 100644 --- a/src/lib/ai/chat-history-config.ts +++ b/src/lib/ai/chat-history-config.ts @@ -1,3 +1,8 @@ +import { + ORG_BILLING_PLANS, + type OrganizationBillingPlan, +} from "../auth/rbac"; + function readPositiveIntEnv(name: string, fallback: number): number { const value = process.env[name]; if (!value) return fallback; @@ -32,3 +37,36 @@ export const chatHistoryConfig = { ), queryLimit: readPositiveIntEnv("AI_CHAT_HISTORY_QUERY_LIMIT", 200), } as const; + +const FREE_CHAT_HISTORY_LIMITS = { + maxMessagesPerThread: readPositiveIntEnv( + "AI_CHAT_HIST_MAX_MSGS_THREAD_FREE", + chatHistoryConfig.maxMessagesPerThread, + ), + maxThreadsPerWorkspace: readPositiveIntEnv( + "AI_CHAT_HIST_MAX_THREADS_FREE", + chatHistoryConfig.maxThreadsPerUser, + ), +}; + +const ORGANIZATIONS_CHAT_HISTORY_LIMITS = { + maxMessagesPerThread: readPositiveIntEnv( + "AI_CHAT_HIST_MAX_MSGS_THREAD_ORGS", + FREE_CHAT_HISTORY_LIMITS.maxMessagesPerThread, + ), + maxThreadsPerWorkspace: readPositiveIntEnv( + "AI_CHAT_HIST_MAX_THREADS_ORGS", + FREE_CHAT_HISTORY_LIMITS.maxThreadsPerWorkspace, + ), +}; + +export function getChatHistoryLimitsForPlan( + plan: OrganizationBillingPlan, +): { + maxMessagesPerThread: number; + maxThreadsPerWorkspace: number; +} { + return plan === ORG_BILLING_PLANS.ORGANIZATIONS + ? ORGANIZATIONS_CHAT_HISTORY_LIMITS + : FREE_CHAT_HISTORY_LIMITS; +} diff --git a/src/lib/auth/rbac.ts b/src/lib/auth/rbac.ts new file mode 100644 index 0000000..b852350 --- /dev/null +++ b/src/lib/auth/rbac.ts @@ -0,0 +1,134 @@ +export const ORG_ROLES = { + ADMIN: "org:admin", + MEMBER: "org:member", +} as const; + +export const ORG_PERMISSIONS = { + FILES_READ: "org:files:read", + FILES_CREATE: "org:files:create", + FILES_DELETE: "org:files:delete", + CHAT_READ: "org:chat:read", + CHAT_CREATE: "org:chat:create", + WORKSPACE_SETTINGS_MANAGE: "org:workspace_settings:manage", + WORKSPACE_MEMBERS_MANAGE: "org:workspace_members:manage", + WORKSPACE_BILLING_MANAGE: "org:workspace_billing:manage", +} as const; + +export const ORG_BILLING_PLAN_KEYS = { + ORGANIZATIONS: "organizations", +} as const; + +export const ORG_BILLING_PLANS = { + FREE: "free", + ORGANIZATIONS: "organizations", +} as const; + +export type OrganizationBillingPlan = + (typeof ORG_BILLING_PLANS)[keyof typeof ORG_BILLING_PLANS]; + +export type OrgRole = (typeof ORG_ROLES)[keyof typeof ORG_ROLES] | string; +export type OrgPermission = + (typeof ORG_PERMISSIONS)[keyof typeof ORG_PERMISSIONS]; + +export const ALL_ORG_PERMISSIONS: OrgPermission[] = [ + ORG_PERMISSIONS.FILES_READ, + ORG_PERMISSIONS.FILES_CREATE, + ORG_PERMISSIONS.FILES_DELETE, + ORG_PERMISSIONS.CHAT_READ, + ORG_PERMISSIONS.CHAT_CREATE, + ORG_PERMISSIONS.WORKSPACE_SETTINGS_MANAGE, + ORG_PERMISSIONS.WORKSPACE_MEMBERS_MANAGE, + ORG_PERMISSIONS.WORKSPACE_BILLING_MANAGE, +]; + +const MEMBER_DEFAULT_PERMISSIONS = new Set([ + ORG_PERMISSIONS.FILES_READ, + ORG_PERMISSIONS.FILES_CREATE, + ORG_PERMISSIONS.CHAT_READ, + ORG_PERMISSIONS.CHAT_CREATE, +]); + +export type PermissionCheckHasFn = + | ((params: any) => boolean) + | null + | undefined; + +export function isOrgAdmin(role: string | null | undefined): boolean { + return role === ORG_ROLES.ADMIN; +} + +export function roleHasPermission( + role: string | null | undefined, + permission: OrgPermission, +): boolean { + if (isOrgAdmin(role)) { + return true; + } + + if (role === ORG_ROLES.MEMBER) { + return MEMBER_DEFAULT_PERMISSIONS.has(permission); + } + + return false; +} + +export function hasOrgPermission(params: { + orgRole: string | null | undefined; + permission: OrgPermission; + has?: PermissionCheckHasFn; + orgPermissions?: string[] | null; +}): boolean { + const { orgRole, permission, has, orgPermissions } = params; + + if (roleHasPermission(orgRole, permission)) { + return true; + } + + if (orgPermissions?.includes(permission)) { + return true; + } + + if (!has) { + return false; + } + + try { + return has({ permission }); + } catch { + return false; + } +} + +export function hasOrganizationPlanOrganizations(params: { + orgId: string | null | undefined; + has?: PermissionCheckHasFn; +}): boolean { + const { orgId, has } = params; + + if (!orgId || !has) { + return false; + } + + try { + return has({ plan: ORG_BILLING_PLAN_KEYS.ORGANIZATIONS }); + } catch { + return false; + } +} + +export function normalizeOrganizationBillingPlan( + value: string | null | undefined, +): OrganizationBillingPlan { + return value === ORG_BILLING_PLAN_KEYS.ORGANIZATIONS + ? ORG_BILLING_PLANS.ORGANIZATIONS + : ORG_BILLING_PLANS.FREE; +} + +export function resolveOrganizationBillingPlanFromHas(params: { + orgId: string | null | undefined; + has?: PermissionCheckHasFn; +}): OrganizationBillingPlan { + return hasOrganizationPlanOrganizations(params) + ? ORG_BILLING_PLANS.ORGANIZATIONS + : ORG_BILLING_PLANS.FREE; +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 5a7a04f..ee84083 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -165,7 +165,7 @@ export const PAGE_SEO = { keywords: [ "SaaS pricing", "free plan", - "pro plan", + "organizations plan", "subscription pricing", "affordable SaaS", ], @@ -243,7 +243,7 @@ export const STRUCTURED_DATA = { "@type": "Offer" as const, price: "12", priceCurrency: "USD", - name: "Pro", + name: "Organizations", priceValidUntil: new Date( new Date().getFullYear() + 1, 11, diff --git a/src/lib/convex-client-provider.tsx b/src/lib/convex-client-provider.tsx index 9e69103..db41443 100644 --- a/src/lib/convex-client-provider.tsx +++ b/src/lib/convex-client-provider.tsx @@ -1,8 +1,7 @@ "use client"; -import { ReactNode } from "react"; -import { ConvexReactClient } from "convex/react"; -import { ConvexProviderWithClerk } from "convex/react-clerk"; +import { ReactNode, useCallback, useEffect, useMemo, useRef } from "react"; +import { ConvexReactClient, ConvexProviderWithAuth } from "convex/react"; import { useAuth } from "@clerk/nextjs"; if (!process.env.NEXT_PUBLIC_CONVEX_URL) { @@ -13,10 +12,57 @@ if (!process.env.NEXT_PUBLIC_CONVEX_URL) { // is a build-time constant that never changes. const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL); +function useConvexClerkAuth() { + const { isLoaded, isSignedIn, getToken, orgId, orgRole } = useAuth(); + const previousOrgIdRef = useRef(undefined); + const shouldForceRefreshRef = useRef(false); + + useEffect(() => { + const previousOrgId = previousOrgIdRef.current; + + if (previousOrgId !== undefined && previousOrgId !== orgId) { + shouldForceRefreshRef.current = true; + } + + previousOrgIdRef.current = orgId; + }, [orgId]); + + const fetchAccessToken = useCallback( + async ({ forceRefreshToken }: { forceRefreshToken: boolean }) => { + try { + const token = await getToken({ + template: "convex", + organizationId: orgId ?? undefined, + skipCache: forceRefreshToken || shouldForceRefreshRef.current, + }); + + shouldForceRefreshRef.current = false; + return token; + } catch (error) { + if (process.env.NODE_ENV !== "production") { + console.warn("Failed to fetch Clerk Convex token", error); + } + return null; + } + }, + // Build a new fetchAccessToken to trigger setAuth() whenever these change. + [getToken, orgId, orgRole], + ); + + return useMemo( + () => ({ + isLoading: !isLoaded, + isAuthenticated: isSignedIn ?? false, + fetchAccessToken, + }), + [isLoaded, isSignedIn, fetchAccessToken], + ); +} + export function ConvexClientProvider({ children }: { children: ReactNode }) { return ( - + {children} - + ); } diff --git a/src/lib/docs.ts b/src/lib/docs.ts index b4c8f93..055ce8e 100644 --- a/src/lib/docs.ts +++ b/src/lib/docs.ts @@ -19,6 +19,7 @@ const DOC_ORDER: string[] = [ "getting-started", "architecture", "authentication", + "multi-tenancy", "ai", "file-upload", "seo", diff --git a/src/lib/emails/escape.ts b/src/lib/emails/escape.ts new file mode 100644 index 0000000..46e8a85 --- /dev/null +++ b/src/lib/emails/escape.ts @@ -0,0 +1,13 @@ +export function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function normalizeEmailToken(value: string, maxLength = 80): string { + return value.replace(/\s+/g, " ").trim().slice(0, maxLength); +} + diff --git a/src/lib/emails/plan-changed.ts b/src/lib/emails/plan-changed.ts index 2932fab..356343e 100644 --- a/src/lib/emails/plan-changed.ts +++ b/src/lib/emails/plan-changed.ts @@ -1,4 +1,5 @@ import { SITE_CONFIG } from "@/lib/constants"; +import { escapeHtml, normalizeEmailToken } from "./escape"; export interface PlanChangedEmailProps { name: string; @@ -12,7 +13,7 @@ export interface PlanChangedEmailProps { * * @param name - The user's display name. * @param previousPlan - The plan they were on (e.g. "free"). - * @param newPlan - The plan they switched to (e.g. "pro"). + * @param newPlan - The plan they switched to (e.g. "organizations"). * @returns An object with `subject` and `html` ready to pass to any email provider. */ export function planChangedEmail({ @@ -21,22 +22,32 @@ export function planChangedEmail({ newPlan, }: PlanChangedEmailProps): { subject: string; html: string } { const isUpgrade = - newPlan.toLowerCase() === "pro" && previousPlan.toLowerCase() === "free"; + newPlan.toLowerCase() === "organizations" && + previousPlan.toLowerCase() === "free"; - const heading = isUpgrade ? "Welcome to Pro" : "Plan Updated"; + const heading = isUpgrade ? "Welcome to Organizations" : "Plan Updated"; const message = isUpgrade - ? "You now have access to all Pro features including higher usage limits, priority support, and early access to new features." + ? "You now have access to the Organizations plan, including workspace-level billing, higher usage limits, and priority support." : `Your plan has been changed from ${previousPlan} to ${newPlan}. Your access has been updated immediately.`; const subject = `Plan Updated - ${SITE_CONFIG.name}`; + const safeSubject = escapeHtml(subject); + const safeHeading = escapeHtml(heading); + const safeName = escapeHtml(normalizeEmailToken(name)); + const safeMessage = escapeHtml(normalizeEmailToken(message, 200)); + const safePreviousPlan = escapeHtml(normalizeEmailToken(previousPlan)); + const safeNewPlan = escapeHtml(normalizeEmailToken(newPlan)); + const safeSiteName = escapeHtml(SITE_CONFIG.name); + const safeSiteUrl = escapeHtml(SITE_CONFIG.url); + const safeSiteHost = escapeHtml(SITE_CONFIG.url.replace("https://", "")); const html = ` - ${subject} + ${safeSubject} @@ -48,7 +59,7 @@ export function planChangedEmail({ @@ -57,7 +68,7 @@ export function planChangedEmail({ @@ -65,7 +76,7 @@ export function planChangedEmail({ @@ -73,7 +84,7 @@ export function planChangedEmail({ @@ -85,14 +96,14 @@ export function planChangedEmail({

- ${SITE_CONFIG.name} + ${safeSiteName}

- ${heading} + ${safeHeading}

- Hi ${name}, + Hi ${safeName},

- ${message} + ${safeMessage}

Previous
-
${previousPlan}
+
${safePreviousPlan}
Current
-
${newPlan}
+
${safeNewPlan}
@@ -102,7 +113,7 @@ export function planChangedEmail({ - Go to Dashboard @@ -118,7 +129,7 @@ export function planChangedEmail({

- ${SITE_CONFIG.name} · ${SITE_CONFIG.url.replace("https://", "")} + ${safeSiteName} · ${safeSiteHost}

diff --git a/src/lib/emails/welcome.ts b/src/lib/emails/welcome.ts index 657c330..e692893 100644 --- a/src/lib/emails/welcome.ts +++ b/src/lib/emails/welcome.ts @@ -1,4 +1,5 @@ import { SITE_CONFIG } from "@/lib/constants"; +import { escapeHtml, normalizeEmailToken } from "./escape"; export interface WelcomeEmailProps { name: string; @@ -15,6 +16,11 @@ export function welcomeEmail({ name }: WelcomeEmailProps): { html: string; } { const subject = `Welcome to ${SITE_CONFIG.name}`; + const safeSubject = escapeHtml(subject); + const safeName = escapeHtml(normalizeEmailToken(name)); + const safeSiteName = escapeHtml(SITE_CONFIG.name); + const safeSiteUrl = escapeHtml(SITE_CONFIG.url); + const safeSiteHost = escapeHtml(SITE_CONFIG.url.replace("https://", "")); const html = ` @@ -22,7 +28,7 @@ export function welcomeEmail({ name }: WelcomeEmailProps): { - ${subject} + ${safeSubject} @@ -34,7 +40,7 @@ export function welcomeEmail({ name }: WelcomeEmailProps): { @@ -53,10 +59,10 @@ export function welcomeEmail({ name }: WelcomeEmailProps): { Welcome

- Hi ${name}, + Hi ${safeName},

- Thanks for joining ${SITE_CONFIG.name}. Your account is ready and you have access to all Free plan features. + Thanks for joining ${safeSiteName}. Your account is ready and you have access to all Free plan features.

Get started by exploring your dashboard. @@ -67,7 +73,7 @@ export function welcomeEmail({ name }: WelcomeEmailProps): {

diff --git a/src/lib/files/config.ts b/src/lib/files/config.ts index e4db033..124d6d0 100644 --- a/src/lib/files/config.ts +++ b/src/lib/files/config.ts @@ -1,3 +1,8 @@ +import { + ORG_BILLING_PLANS, + type OrganizationBillingPlan, +} from "../auth/rbac"; + export const FILE_TYPE_CATALOG = [ { mimeType: "image/jpeg", @@ -78,6 +83,16 @@ function readNonNegativeIntEnv(name: string, fallback: number): number { return Math.floor(parsed); } +function readPositiveIntEnv(name: string, fallback: number): number { + const value = process.env[name]; + if (!value) return fallback; + + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + + return Math.floor(parsed); +} + export const ALLOWED_FILE_MIME_TYPES = FILE_TYPE_CATALOG.map( (fileType) => fileType.mimeType, ); @@ -86,20 +101,74 @@ export const ACCEPTED_FILE_EXTENSIONS = FILE_TYPE_CATALOG.flatMap( (fileType) => fileType.extensions, ).join(","); -export const FILE_STORAGE_LIMITS = { - maxFileSizeBytes: 10 * 1024 * 1024, - maxFilesPerUser: 100, -} as const; - -export const FILE_UPLOAD_RATE_LIMITS = { - image: { - maxUploadsPerWindow: readNonNegativeIntEnv( - "FILE_IMAGE_UPLOAD_RATE_LIMIT_MAX_UPLOADS", - 10, - ), - windowMs: readNonNegativeIntEnv("FILE_IMAGE_UPLOAD_RATE_LIMIT_WINDOW_MS", 60_000), - }, -} as const; +const FREE_FILE_LIMITS = { + maxFileSizeBytes: readPositiveIntEnv( + "FILE_MAX_FILE_SIZE_BYTES_FREE", + 10 * 1024 * 1024, + ), + maxFilesPerUser: readPositiveIntEnv("FILE_MAX_FILES_PER_USER_FREE", 100), + imageMaxUploadsPerWindow: readNonNegativeIntEnv( + "FILE_IMG_RL_MAX_UPLOADS_FREE", + readNonNegativeIntEnv("FILE_IMAGE_UPLOAD_RATE_LIMIT_MAX_UPLOADS", 10), + ), +}; + +const ORGANIZATIONS_FILE_LIMITS = { + maxFileSizeBytes: readPositiveIntEnv( + "FILE_MAX_FILE_SIZE_BYTES_ORGANIZATIONS", + FREE_FILE_LIMITS.maxFileSizeBytes, + ), + maxFilesPerUser: readPositiveIntEnv( + "FILE_MAX_FILES_PER_USER_ORGANIZATIONS", + FREE_FILE_LIMITS.maxFilesPerUser, + ), + imageMaxUploadsPerWindow: readNonNegativeIntEnv( + "FILE_IMG_RL_MAX_UPLOADS_ORGS", + FREE_FILE_LIMITS.imageMaxUploadsPerWindow, + ), +}; + +const FILE_UPLOAD_WINDOW_MS = readNonNegativeIntEnv( + "FILE_IMAGE_UPLOAD_RATE_LIMIT_WINDOW_MS", + 60_000, +); + +export function getFileStorageLimitsForPlan(plan: OrganizationBillingPlan): { + maxFileSizeBytes: number; + maxFilesPerUser: number; +} { + const limits = + plan === ORG_BILLING_PLANS.ORGANIZATIONS + ? ORGANIZATIONS_FILE_LIMITS + : FREE_FILE_LIMITS; + + return { + maxFileSizeBytes: limits.maxFileSizeBytes, + maxFilesPerUser: limits.maxFilesPerUser, + }; +} + +export function getFileImageUploadRateLimitForPlan( + plan: OrganizationBillingPlan, +): { + maxUploadsPerWindow: number; + windowMs: number; +} { + const limits = + plan === ORG_BILLING_PLANS.ORGANIZATIONS + ? ORGANIZATIONS_FILE_LIMITS + : FREE_FILE_LIMITS; + + return { + maxUploadsPerWindow: limits.imageMaxUploadsPerWindow, + windowMs: FILE_UPLOAD_WINDOW_MS, + }; +} + +// Backwards-compatible default values used by existing UI fallbacks. +export const FILE_STORAGE_LIMITS = getFileStorageLimitsForPlan( + ORG_BILLING_PLANS.FREE, +); const FILE_MIME_LABELS = Object.fromEntries( FILE_TYPE_CATALOG.map((fileType) => [fileType.mimeType, fileType.label]), diff --git a/src/lib/security/request.ts b/src/lib/security/request.ts new file mode 100644 index 0000000..a6c2224 --- /dev/null +++ b/src/lib/security/request.ts @@ -0,0 +1,60 @@ +function firstHeaderValue(value: string | null): string | null { + if (!value) { + return null; + } + + return value + .split(",")[0] + ?.trim() + .toLowerCase() ?? null; +} + +function defaultPortForProtocol(protocol: string): string { + if (protocol === "https:") { + return "443"; + } + if (protocol === "http:") { + return "80"; + } + return ""; +} + +/** + * Reject cross-origin browser requests for cookie-authenticated state-changing endpoints. + * If Origin is missing (e.g. curl/server-to-server), we allow the request. + */ +export function hasTrustedOrigin(req: Request): boolean { + const origin = req.headers.get("origin"); + if (!origin) { + return true; + } + + const host = + firstHeaderValue(req.headers.get("x-forwarded-host")) ?? + firstHeaderValue(req.headers.get("host")); + if (!host) { + return false; + } + + const forwardedProto = firstHeaderValue(req.headers.get("x-forwarded-proto")); + const reqUrl = new URL(req.url); + const protocol = forwardedProto ?? reqUrl.protocol.replace(":", ""); + + try { + const requestOrigin = new URL(origin); + const expectedOrigin = new URL(`${protocol}://${host}`); + + const requestPort = + requestOrigin.port || defaultPortForProtocol(requestOrigin.protocol); + const expectedPort = + expectedOrigin.port || defaultPortForProtocol(expectedOrigin.protocol); + + return ( + requestOrigin.protocol === expectedOrigin.protocol && + requestOrigin.hostname === expectedOrigin.hostname && + requestPort === expectedPort + ); + } catch { + return false; + } +} diff --git a/src/proxy.ts b/src/proxy.ts index aabc9d0..d841ba5 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -19,12 +19,15 @@ const cspDirectives = [ "upgrade-insecure-requests", ].join("; "); -const isProtectedRoute = createRouteMatcher(["/dashboard(.*)"]); +const isDashboardRoute = createRouteMatcher(["/dashboard(.*)"]); +const isOnboardingRoute = createRouteMatcher(["/onboarding(.*)"]); +const isProtectedRoute = (req: Parameters[0]) => + isDashboardRoute(req) || isOnboardingRoute(req); const isAuthRoute = createRouteMatcher(["/sign-in(.*)", "/sign-up(.*)"]); const isRootRoute = createRouteMatcher(["/"]); export default clerkMiddleware(async (auth, req) => { - const { userId } = await auth(); + const { userId, orgId } = await auth(); // Redirect authenticated users from auth pages to dashboard if (userId && isAuthRoute(req)) { @@ -38,11 +41,17 @@ export default clerkMiddleware(async (auth, req) => { return NextResponse.redirect(dashboardUrl); } - // Protect dashboard routes: unauthenticated users get redirected to sign-in + // Protect dashboard + onboarding routes: unauthenticated users get redirected to sign-in if (isProtectedRoute(req)) { await auth.protect(); } + // Organization-required multi-tenant mode for protected app routes. + if (userId && isDashboardRoute(req) && !orgId) { + const onboardingUrl = new URL("/onboarding", req.url); + return NextResponse.redirect(onboardingUrl); + } + const response = NextResponse.next(); // ──────────────────────────────────────────────

- ${SITE_CONFIG.name} + ${safeSiteName}

- Go to Dashboard → @@ -85,8 +91,8 @@ export function welcomeEmail({ name }: WelcomeEmailProps): {

- ${SITE_CONFIG.name}
- ${SITE_CONFIG.url.replace("https://", "")} + ${safeSiteName}
+ ${safeSiteHost}