diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..22a1190 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,281 @@ +# AGENTS.md - Convex Turbo Monorepo + +## Quick Reference + +### Build/Lint/Test Commands + +```bash +# Development +bun dev # Run all apps (Next.js + Expo + Backend) +bun dev:next # Run Next.js + Convex backend only +bun dev:expo # Run Expo + Convex backend only +bun dev:backend # Run Convex backend only +bun dev:expo:tunnel # Expo with tunnel for physical device + +# Quality +bun lint # Lint all packages +bun lint:fix # Lint and auto-fix +bun format # Check formatting +bun format:fix # Fix formatting +bun typecheck # TypeScript type checking + +# Build +bun build # Build all packages + +# Single Package Commands (use Turborepo filters) +bun turbo run dev -F @gib/next # Single app dev +bun turbo run lint -F @gib/backend # Lint single package +bun turbo run typecheck -F @gib/ui # Typecheck single package + +# Cleanup +bun clean # Clean all node_modules (git clean) +bun clean:ws # Clean workspace caches +``` + +## Project Structure + +``` +convex-monorepo/ +├── apps/ +│ ├── next/ # Next.js 16 web app (@gib/next) +│ └── expo/ # Expo 54 mobile app (@gib/expo) +├── packages/ +│ ├── backend/ # Convex backend (@gib/backend) +│ │ └── convex/ # Convex functions, schema, auth +│ └── ui/ # Shared shadcn/ui components (@gib/ui) +├── tools/ +│ ├── eslint/ # @gib/eslint-config +│ ├── prettier/ # @gib/prettier-config +│ ├── tailwind/ # @gib/tailwind-config +│ └── typescript/ # @gib/tsconfig +├── docker/ # Self-hosted Convex deployment +└── .env # Central environment variables +``` + +## Dependency Management + +### Catalogs (Single Source of Truth) + +All shared dependencies are defined in root `package.json` catalogs: + +```json +"catalog": { + "prettier": "^3.6.2", + "typescript": "^5.9.3", + "eslint": "^9.38.0" +}, +"catalogs": { + "convex": { "convex": "^1.28.0", "@convex-dev/auth": "^0.0.81" }, + "react19": { "react": "^19.1.4", "react-dom": "19.1.4" } +} +``` + +### Using Catalogs in Packages + +```json +"dependencies": { + "convex": "catalog:convex", + "react": "catalog:react19", + "typescript": "catalog:" +}, +"devDependencies": { + "@gib/eslint-config": "workspace:*", + "@gib/ui": "workspace:*" +} +``` + +### Updating Dependencies + +**IMPORTANT:** Do NOT use `bun update` directly - it may replace catalog: with versions. + +```bash +# Correct workflow: +1. Edit version in root package.json catalog section +2. Run: bun install +3. Verify with: bun lint:ws (runs sherif) +``` + +## Code Style Guidelines + +### Imports (via @gib/prettier-config) + +```typescript +// Order: Types → React → Next/Expo → Third-party → @gib/* → Local +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { api } from '@/convex/_generated/api'; +import { ConvexError } from 'convex/values'; + +import { cn } from '@gib/ui'; + +import type { User } from './types'; +``` + +### TypeScript + +- Strict mode enabled (`noUncheckedIndexedAccess: true`) +- Use `type` imports: `import type { X } from 'y'` +- Prefix unused vars with `_`: `const _unused = ...` +- Avoid `any` - use `unknown` with type guards + +### Naming Conventions + +- Components: `PascalCase` (`UserProfile.tsx`) +- Functions/variables: `camelCase` +- Constants: `SCREAMING_SNAKE_CASE` +- Files: `kebab-case.ts` (except components) + +### Error Handling + +```typescript +// Convex functions - use ConvexError +import { ConvexError } from 'convex/values'; +throw new ConvexError('User not found.'); + +// Client-side - handle gracefully +try { ... } catch (e) { + if (e instanceof ConvexError) { /* handle */ } +} +``` + +### Formatting + +- Single quotes, trailing commas +- 80 char line width, 2 space indent +- Tailwind classes sorted via prettier-plugin-tailwindcss +- Use `cn()` for conditional classes: `cn('base', condition && 'active')` + +## Environment Variables + +### Central .env (root) + +All env vars in `/.env`. Apps load via `with-env` script: + +```json +"scripts": { + "dev": "bun with-env next dev --turbo", + "with-env": "dotenv -e ../../.env --" +} +``` + +### Required Variables + +```bash +# Convex +CONVEX_SELF_HOSTED_URL=https://api.convex.example.com +CONVEX_SELF_HOSTED_ADMIN_KEY= +CONVEX_SITE_URL=https://convex.example.com +NEXT_PUBLIC_CONVEX_URL=https://api.convex.example.com + +# Auth (sync to Convex deployment) +AUTH_AUTHENTIK_ID= +AUTH_AUTHENTIK_SECRET= +AUTH_AUTHENTIK_ISSUER= +USESEND_API_KEY= +``` + +### Syncing to Convex Deployment + +```bash +# Upload env vars to self-hosted Convex +npx convex env set AUTH_AUTHENTIK_ID "value" +npx convex env set AUTH_AUTHENTIK_SECRET "value" +npx convex env set AUTH_AUTHENTIK_ISSUER "value" +npx convex env set USESEND_API_KEY "value" +npx convex env set CONVEX_SITE_URL "https://convex.example.com" +``` + +## Convex Backend Patterns + +### Schema (`packages/backend/convex/schema.ts`) + +```typescript +import { defineSchema, defineTable } from 'convex/server'; +import { authTables } from '@convex-dev/auth/server'; + +export default defineSchema({ + ...authTables, + users: defineTable({ ... }).index('email', ['email']), +}); +``` + +### Queries & Mutations + +```typescript +import { v } from 'convex/values'; + +import { mutation, query } from './_generated/server'; + +export const getUser = query({ + args: { userId: v.id('users') }, + handler: async (ctx, args) => { + return await ctx.db.get(args.userId); + }, +}); +``` + +### Auth + +```typescript +import { getAuthUserId } from '@convex-dev/auth/server'; + +// In any query/mutation: +const userId = await getAuthUserId(ctx); +if (!userId) throw new ConvexError('Not authenticated.'); +``` + +## Next.js Patterns + +### Convex Provider Setup + +```tsx +// src/components/providers/ConvexClientProvider.tsx +'use client'; + +import { ConvexAuthNextjsProvider } from '@convex-dev/auth/nextjs'; +import { ConvexReactClient } from 'convex/react'; + +const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); + +export const ConvexClientProvider = ({ children }) => ( + + {children} + +); +``` + +### Path Aliases + +- `@/*` → `./src/*` (Next.js) +- `~/*` → `./src/*` (Expo) + +## Expo Patterns + +### NativeWind (Tailwind for RN) + +```tsx +import '../styles.css'; + +// Use className like web: +``` + +### Secure Storage for Auth + +```typescript +import * as SecureStore from 'expo-secure-store'; + +// Tokens stored via SecureStore, not AsyncStorage +``` + +## Known Issues / Cleanup Needed + +1. **Apps reference @acme/_ instead of @gib/_** - Legacy T3 template imports +2. **TRPC imports exist but no TRPC** - Replace with Convex client +3. **Expo uses better-auth** - Should use @convex-dev/auth +4. **@gib/ui uses pnpm dlx** - Should use bunx for shadcn + +## Adding UI Components + +```bash +bun ui-add # Interactive shadcn/ui component addition +``` diff --git a/apps/next/next.config.js b/apps/next/next.config.js index 28980dc..eab557c 100644 --- a/apps/next/next.config.js +++ b/apps/next/next.config.js @@ -1,11 +1,6 @@ import { withSentryConfig } from '@sentry/nextjs'; -import { createJiti } from 'jiti'; import { withPlausibleProxy } from 'next-plausible'; - -const jiti = createJiti(import.meta.url); - -// Import env files to validate at build time. Use jiti so we can load .ts files in here. -await jiti.import('./src/env'); +import { env } from './src/env.js'; /** @type {import("next").NextConfig} */ const config = withPlausibleProxy({ diff --git a/apps/next/package.json b/apps/next/package.json index 998f42d..0bb7d72 100644 --- a/apps/next/package.json +++ b/apps/next/package.json @@ -38,7 +38,6 @@ "@types/react": "catalog:react19", "@types/react-dom": "catalog:react19", "eslint": "catalog:", - "jiti": "^2.5.1", "prettier": "catalog:", "tailwindcss": "catalog:", "tw-animate-css": "^1.4.0", diff --git a/apps/next/public/misc/auth/gibs_auth_wide_header.png b/apps/next/public/misc/auth/gibs_auth_wide_header.png new file mode 100644 index 0000000..a28f7a9 Binary files /dev/null and b/apps/next/public/misc/auth/gibs_auth_wide_header.png differ diff --git a/apps/next/src/app/(auth)/sign-in/page.tsx b/apps/next/src/app/(auth)/sign-in/page.tsx new file mode 100644 index 0000000..fce0a5e --- /dev/null +++ b/apps/next/src/app/(auth)/sign-in/page.tsx @@ -0,0 +1,460 @@ +'use client'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import Link from 'next/link'; +import { useAuthActions } from '@convex-dev/auth/react'; +import { useRouter } from 'next/navigation'; +import { ConvexError } from 'convex/values'; +import { useState } from 'react'; +import { + Card, + CardContent, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + InputOTP, + InputOTPGroup, + InputOTPSlot, + InputOTPSeparator, + Separator, + SubmitButton, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@gib/ui'; +import { toast } from 'sonner'; +import { + GibsAuthSignInButton, +} from '@/components/layout/auth/buttons'; +import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@gib/backend/types'; + +const signInFormSchema = z.object({ + email: z.email({ + message: 'Please enter a valid email address.', + }), + password: z.string().regex(PASSWORD_REGEX, { + message: 'Incorrect password. Does not meet requirements.', + }), +}); + +const signUpFormSchema = z + .object({ + name: z.string().min(2, { + message: 'Name must be at least 2 characters.', + }), + email: z.email({ + message: 'Please enter a valid email address.', + }), + password: z + .string() + .min(PASSWORD_MIN, { + message: `Password must be at least ${PASSWORD_MIN} characters.`, + }) + .max(PASSWORD_MAX, { + message: `Password must be no more than ${PASSWORD_MAX} characters.`, + }) + .regex(/^\S+$/, { + message: 'Password must not contain whitespace.', + }) + .regex(/[0-9]/, { + message: 'Password must contain at least one digit.', + }) + .regex(/[a-z]/, { + message: 'Password must contain at least one lowercase letter.', + }) + .regex(/[A-Z]/, { + message: 'Password must contain at least one uppercase letter.', + }) + .regex(/[\p{P}\p{S}]/u, { + message: 'Password must contain at least one symbol.', + }), + confirmPassword: z.string().min(PASSWORD_MIN, { + message: `Password must be at least ${PASSWORD_MIN} characters.`, + }), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match!', + path: ['confirmPassword'], + }); + +const verifyEmailFormSchema = z.object({ + code: z.string({ message: 'Invalid code.' }), +}); + +const SignIn = () => { + const { signIn } = useAuthActions(); + const [flow, setFlow] = useState<'signIn' | 'signUp' | 'email-verification'>( + 'signIn', + ); + const [email, setEmail] = useState(''); + const [code, setCode] = useState(''); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + const signInForm = useForm>({ + resolver: zodResolver(signInFormSchema), + defaultValues: { email: '', password: '' }, + }); + + const signUpForm = useForm>({ + resolver: zodResolver(signUpFormSchema), + defaultValues: { + name: '', + email, + password: '', + confirmPassword: '', + }, + }); + + const verifyEmailForm = useForm>({ + resolver: zodResolver(verifyEmailFormSchema), + defaultValues: { code }, + }); + + const handleSignIn = async (values: z.infer) => { + const formData = new FormData(); + formData.append('email', values.email); + formData.append('password', values.password); + formData.append('flow', flow); + setLoading(true); + try { + await signIn('password', formData).then(() => router.push('/')); + } catch (error) { + console.error('Error signing in:', error); + toast.error('Error signing in.'); + } finally { + signInForm.reset(); + setLoading(false); + } + }; + + const handleSignUp = async (values: z.infer) => { + const formData = new FormData(); + formData.append('email', values.email); + formData.append('password', values.password); + formData.append('flow', flow); + formData.append('name', values.name); + setLoading(true); + try { + if (values.confirmPassword !== values.password) + throw new ConvexError('Passwords do not match.'); + await signIn('password', formData).then(() => { + setEmail(values.email); + setFlow('email-verification'); + }); + } catch (error) { + console.error('Error signing up:', error); + toast.error('Error signing up.'); + } finally { + signUpForm.reset(); + setLoading(false); + } + }; + + const handleVerifyEmail = async ( + values: z.infer, + ) => { + const formData = new FormData(); + formData.append('code', code); + formData.append('flow', flow); + formData.append('email', email); + setLoading(true); + try { + await signIn('password', formData).then(() => router.push('/')); + } catch (error) { + console.error('Error verifying email:', error); + toast.error('Error verifying email.'); + } finally { + verifyEmailForm.reset(); + setLoading(false); + } + }; + + if (flow === 'email-verification') { + return ( +
+ + +
+

Verify Your Email

+

We sent a code to {email}

+
+
+ + ( + + Code + + setCode(value)} + > + + + + + + + + + + + + + Please enter the one-time password sent to your email. + +
+ +
+
+ )} + /> + + Verify Email + + + +
+ +
+
+
+
+ ); + } + + return ( +
+ + setFlow(value as 'signIn' | 'signUp')} + className='items-center' + > + + + Sign In + + + Sign Up + + + + + +
+ + ( + + Email + + + +
+ +
+
+ )} + /> + ( + +
+ Password + + Forgot Password? + +
+ + + +
+ +
+
+ )} + /> + + Sign In + + + +
+
+ + or + +
+
+
+ +
+
+
+
+ + + +
+ + ( + + Name + + + +
+ +
+
+ )} + /> + ( + + Email + + + +
+ +
+
+ )} + /> + ( + + Password + + + +
+ +
+
+ )} + /> + ( + + + Confirm Passsword + + + + +
+ +
+
+ )} + /> + + Sign Up + + + +
+
+ + or + +
+
+
+ +
+
+
+
+
+
+
+ ); +}; +export default SignIn; diff --git a/apps/next/src/app/layout.tsx b/apps/next/src/app/layout.tsx index 872bcd4..1502b40 100644 --- a/apps/next/src/app/layout.tsx +++ b/apps/next/src/app/layout.tsx @@ -1,34 +1,16 @@ import type { Metadata, Viewport } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; -import { cn } from '@acme/ui'; -import { ThemeProvider, ThemeToggle } from '@acme/ui/theme'; -import { Toaster } from '@acme/ui/toast'; -import { env } from '~/env'; -import { TRPCReactProvider } from '~/trpc/react'; +import '@/app/styles.css'; -import '~/app/styles.css'; +import { ConvexClientProvider } from '@/components/providers'; +import { generateMetadata } from '@/lib/metadata'; +import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server'; +import PlausibleProvider from 'next-plausible'; -export const metadata: Metadata = { - metadataBase: new URL( - env.VERCEL_ENV === 'production' - ? 'https://turbo.t3.gg' - : 'http://localhost:3000', - ), - title: 'Create T3 Turbo', - description: 'Simple monorepo with shared backend for web & mobile apps', - openGraph: { - title: 'Create T3 Turbo', - description: 'Simple monorepo with shared backend for web & mobile apps', - url: 'https://create-t3-turbo.vercel.app', - siteName: 'Create T3 Turbo', - }, - twitter: { - card: 'summary_large_image', - site: '@jullerino', - creator: '@jullerino', - }, -}; +import { ThemeProvider, Toaster } from '@gib/ui'; + +export const metadata: Metadata = generateMetadata(); export const viewport: Viewport = { themeColor: [ @@ -46,24 +28,36 @@ const geistMono = Geist_Mono({ variable: '--font-geist-mono', }); -export default function RootLayout(props: { children: React.ReactNode }) { +const RootLayout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { return ( - - + - - {props.children} -
- -
- -
- - + + + + + {children} + + + + + +
+ ); -} +}; +export default RootLayout; diff --git a/apps/next/src/app/page.tsx b/apps/next/src/app/page.tsx index 106c875..c1aacb6 100644 --- a/apps/next/src/app/page.tsx +++ b/apps/next/src/app/page.tsx @@ -1,41 +1,11 @@ -import { Suspense } from 'react'; - -import { HydrateClient, prefetch, trpc } from '~/trpc/server'; -import { AuthShowcase } from './_components/auth-showcase'; -import { - CreatePostForm, - PostCardSkeleton, - PostList, -} from './_components/posts'; - -export default function HomePage() { - prefetch(trpc.post.all.queryOptions()); +'use client'; +const Home = () => { return ( - -
-
-

- Create T3 Turbo -

- - - -
- - - - -
- } - > - - -
- -
-
+
+ Hello! +
); -} +}; + +export default Home; diff --git a/apps/next/src/app/styles.css b/apps/next/src/app/styles.css index 42afca7..552d3f4 100644 --- a/apps/next/src/app/styles.css +++ b/apps/next/src/app/styles.css @@ -1,6 +1,6 @@ @import 'tailwindcss'; @import 'tw-animate-css'; -@import '@acme/tailwind-config/theme'; +@import '@gib/tailwind-config/theme'; @source '../../../../packages/ui/src/*.{ts,tsx}'; diff --git a/apps/next/src/components/layout/auth/buttons/gibs-auth.tsx b/apps/next/src/components/layout/auth/buttons/gibs-auth.tsx new file mode 100644 index 0000000..590cdb6 --- /dev/null +++ b/apps/next/src/components/layout/auth/buttons/gibs-auth.tsx @@ -0,0 +1,46 @@ +import Image from 'next/image'; +import { useAuthActions } from '@convex-dev/auth/react'; +import type { buttonVariants } from '@gib/ui'; +import { Button } from '@gib/ui'; +import type { ComponentProps } from 'react'; +import type { VariantProps } from 'class-variance-authority'; + +interface Props { + buttonProps?: Omit, 'onClick'> & + VariantProps & { + asChild?: boolean; + }; + type?: 'signIn' | 'signUp'; +}; + +export const GibsAuthSignInButton = ({ + buttonProps, + type = 'signIn', +}: Props) => { + const { signIn } = useAuthActions(); + return ( + + ); +}; diff --git a/apps/next/src/components/layout/auth/buttons/index.tsx b/apps/next/src/components/layout/auth/buttons/index.tsx new file mode 100644 index 0000000..c67c893 --- /dev/null +++ b/apps/next/src/components/layout/auth/buttons/index.tsx @@ -0,0 +1 @@ +export { GibsAuthSignInButton } from './gibs-auth'; diff --git a/apps/next/src/components/providers/ConvexClientProvider.tsx b/apps/next/src/components/providers/ConvexClientProvider.tsx new file mode 100644 index 0000000..0b9c22f --- /dev/null +++ b/apps/next/src/components/providers/ConvexClientProvider.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { ConvexAuthNextjsProvider } from "@convex-dev/auth/nextjs"; +import { ConvexReactClient } from "convex/react"; +import type { ReactNode } from "react"; +import { env } from '@/env'; + +const convex = new ConvexReactClient(env.NEXT_PUBLIC_CONVEX_URL); + +export function ConvexClientProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/next/src/components/providers/index.tsx b/apps/next/src/components/providers/index.tsx new file mode 100644 index 0000000..809a7c6 --- /dev/null +++ b/apps/next/src/components/providers/index.tsx @@ -0,0 +1 @@ +export { ConvexClientProvider } from './ConvexClientProvider'; diff --git a/apps/next/src/env.ts b/apps/next/src/env.js similarity index 100% rename from apps/next/src/env.ts rename to apps/next/src/env.js diff --git a/apps/next/src/lib/metadata.ts b/apps/next/src/lib/metadata.ts new file mode 100644 index 0000000..955a984 --- /dev/null +++ b/apps/next/src/lib/metadata.ts @@ -0,0 +1,129 @@ +import type { Metadata } from 'next'; +import * as Sentry from '@sentry/nextjs'; + +export const generateMetadata = (): Metadata => { + return { + title: { + template: '%s | Convex Monorepo', + default: 'Convex Monorepo', + }, + description: 'A Convex Monorepo with Next.js & Expo', + applicationName: 'Convex Monorepo', + keywords: + 'Convex Monorepo,T3 Template, Nextjs, ' + + 'Tailwind, TypeScript, React, T3, Gib', + authors: [{ name: 'Gib', url: 'https://gbrown.org' }], + creator: 'Gib Brown', + publisher: 'Gib Brown', + formatDetection: { + email: false, + address: false, + telephone: false, + }, + robots: { + index: true, + follow: true, + nocache: false, + googleBot: { + index: true, + follow: true, + noimageindex: false, + 'max-video-preview': -1, + 'max-image-preview': 'large', + 'max-snippet': -1, + }, + }, + icons: { + icon: [ + { url: '/favicon.ico', type: 'image/x-icon', sizes: 'any' }, + { + url: '/favicon.ico', + type: 'image/x-icon', + sizes: 'any', + media: '(prefers-color-scheme: dark)', + }, + //{ + //url: '/appicon/icon.png', + //type: 'image/png', + //sizes: '192x192', + //}, + //{ + //url: '/appicon/icon.png', + //type: 'image/png', + //sizes: '192x192', + //media: '(prefers-color-scheme: dark)', + //}, + ], + //shortcut: [ + //{ + //url: '/appicon/icon.png', + //type: 'image/png', + //sizes: '192x192', + //}, + //{ + //url: '/appicon/icon.png', + //type: 'image/png', + //sizes: '192x192', + //media: '(prefers-color-scheme: dark)', + //}, + //], + //apple: [ + //{ + //url: 'appicon/icon.png', + //type: 'image/png', + //sizes: '192x192', + //}, + //{ + //url: 'appicon/icon.png', + //type: 'image/png', + //sizes: '192x192', + //media: '(prefers-color-scheme: dark)', + //}, + //], + //other: [ + //{ + //rel: 'apple-touch-icon-precomposed', + //url: '/appicon/icon-precomposed.png', + //type: 'image/png', + //sizes: '180x180', + //}, + //], + }, + other: { + ...Sentry.getTraceData(), + }, + //appleWebApp: { + //title: 'Convex Monorepo', + //statusBarStyle: 'black-translucent', + //startupImage: [ + //'/icons/apple/splash-768x1004.png', + //{ + //url: '/icons/apple/splash-1536x2008.png', + //media: '(device-width: 768px) and (device-height: 1024px)', + //}, + //], + //}, + verification: { + google: 'google', + yandex: 'yandex', + yahoo: 'yahoo', + }, + category: 'technology', + /* + appLinks: { + ios: { + url: 'https://techtracker.gbrown.org/ios', + app_store_id: 'com.gbrown.techtracker', + }, + android: { + package: 'https://techtracker.gbrown.org/android', + app_name: 'app_t3_template', + }, + web: { + url: 'https://techtracker.gbrown.org', + should_fallback: true, + }, + }, + */ + }; +}; diff --git a/apps/next/src/lib/middleware/ban-sus-ips.ts b/apps/next/src/lib/middleware/ban-sus-ips.ts new file mode 100644 index 0000000..72f6e98 --- /dev/null +++ b/apps/next/src/lib/middleware/ban-sus-ips.ts @@ -0,0 +1,199 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; + +// In-memory stores for tracking IPs (use Redis in production) +const ipAttempts = new Map(); +const ip404Attempts = new Map(); +const bannedIPs = new Set(); + +// Suspicious patterns that indicate malicious activity +const MALICIOUS_PATTERNS = [ + // Your existing patterns + /web-inf/i, + /\.jsp/i, + /\.php/i, + /puttest/i, + /WEB-INF/i, + /\.xml$/i, + /perl/i, + /xampp/i, + /phpwebgallery/i, + /FileManager/i, + /standalonemanager/i, + /h2console/i, + /WebAdmin/i, + /login_form\.php/i, + /%2e/i, + /%u002e/i, + /\.%00/i, + /\.\./, + /lcgi/i, + + // New patterns from your logs + /\/appliance\//i, + /bomgar/i, + /netburner-logo/i, + /\/ui\/images\//i, + /logon_merge/i, + /logon_t\.gif/i, + /login_top\.gif/i, + /theme1\/images/i, + /\.well-known\/acme-challenge\/.*\.jpg$/i, + /\.well-known\/pki-validation\/.*\.jpg$/i, + + // Path traversal and system file access patterns + /\/etc\/passwd/i, + /\/etc%2fpasswd/i, + /\/etc%5cpasswd/i, + /\/\/+etc/i, + /\\\\+.*etc/i, + /%2f%2f/i, + /%5c%5c/i, + /\/\/+/, + /\\\\+/, + /%00/i, + /%23/i, + + // Encoded path traversal attempts + /%2e%2e/i, + /%252e/i, + /%c0%ae/i, + /%c1%9c/i, +]; + +// Suspicious HTTP methods +const SUSPICIOUS_METHODS = ['TRACE', 'PUT', 'DELETE', 'PATCH']; + +const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute +const MAX_ATTEMPTS = 10; // Max suspicious requests per window +const BAN_DURATION = 30 * 60 * 1000; // 30 minutes + +// 404 rate limiting settings +const RATE_404_WINDOW = 2 * 60 * 1000; // 2 minutes +const MAX_404_ATTEMPTS = 10; // Max 404s before ban + +const getClientIP = (request: NextRequest): string => { + const forwarded = request.headers.get('x-forwarded-for'); + const realIP = request.headers.get('x-real-ip'); + const cfConnectingIP = request.headers.get('cf-connecting-ip'); + + if (forwarded) return (forwarded.split(',')[0] ?? '').trim(); + if (realIP) return realIP; + if (cfConnectingIP) return cfConnectingIP; + return request.headers.get('host') ?? 'unknown'; +}; + +const isPathSuspicious = (pathname: string): boolean => { + return MALICIOUS_PATTERNS.some((pattern) => pattern.test(pathname)); +}; + +const isMethodSuspicious = (method: string): boolean => { + return SUSPICIOUS_METHODS.includes(method); +}; + +const updateIPAttempts = (ip: string): boolean => { + const now = Date.now(); + const attempts = ipAttempts.get(ip); + + if (!attempts || now - attempts.lastAttempt > RATE_LIMIT_WINDOW) { + ipAttempts.set(ip, { count: 1, lastAttempt: now }); + return false; + } + + attempts.count++; + attempts.lastAttempt = now; + + if (attempts.count > MAX_ATTEMPTS) { + bannedIPs.add(ip); + ipAttempts.delete(ip); + + setTimeout(() => { + bannedIPs.delete(ip); + }, BAN_DURATION); + + return true; + } + + return false; +}; + +const update404Attempts = (ip: string): boolean => { + const now = Date.now(); + const attempts = ip404Attempts.get(ip); + + if (!attempts || now - attempts.lastAttempt > RATE_404_WINDOW) { + ip404Attempts.set(ip, { count: 1, lastAttempt: now }); + return false; + } + + attempts.count++; + attempts.lastAttempt = now; + + if (attempts.count > MAX_404_ATTEMPTS) { + bannedIPs.add(ip); + ip404Attempts.delete(ip); + + console.log( + `🔨 IP ${ip} banned for excessive 404 requests (${attempts.count} in ${RATE_404_WINDOW / 1000}s)`, + ); + + setTimeout(() => { + bannedIPs.delete(ip); + }, BAN_DURATION); + + return true; + } + + return false; +}; + +export const banSuspiciousIPs = (request: NextRequest): NextResponse | null => { + const { pathname } = request.nextUrl; + const method = request.method; + const ip = getClientIP(request); + + // Check if IP is already banned + if (bannedIPs.has(ip)) { + return new NextResponse('Access denied.', { status: 403 }); + } + + const isSuspiciousPath = isPathSuspicious(pathname); + const isSuspiciousMethod = isMethodSuspicious(method); + + // Handle suspicious activity + if (isSuspiciousPath || isSuspiciousMethod) { + const shouldBan = updateIPAttempts(ip); + + if (shouldBan) { + console.log(`🔨 IP ${ip} has been banned for suspicious activity`); + return new NextResponse('Access denied - IP banned. Please fuck off.', { + status: 403, + }); + } + + return new NextResponse('Not Found', { status: 404 }); + } + + return null; +}; + +// Call this function when you detect a 404 response +export const handle404Response = ( + request: NextRequest, +): NextResponse | null => { + const ip = getClientIP(request); + + if (bannedIPs.has(ip)) { + return new NextResponse('Access denied.', { status: 403 }); + } + + const shouldBan = update404Attempts(ip); + + if (shouldBan) { + return new NextResponse('Access denied - IP banned for excessive 404s.', { + status: 403, + }); + } + + return null; +}; diff --git a/apps/next/src/middleware.ts b/apps/next/src/middleware.ts new file mode 100644 index 0000000..a44a019 --- /dev/null +++ b/apps/next/src/middleware.ts @@ -0,0 +1,34 @@ +import { + convexAuthNextjsMiddleware, + createRouteMatcher, + nextjsMiddlewareRedirect, +} from '@convex-dev/auth/nextjs/server'; +import { banSuspiciousIPs } from '@/lib/middleware/ban-sus-ips'; + +const isSignInPage = createRouteMatcher(['/sign-in']); +const isProtectedRoute = createRouteMatcher(['/', '/profile']); + +export default convexAuthNextjsMiddleware( + async (request, { convexAuth }) => { + const banResponse = banSuspiciousIPs(request); + if (banResponse) return banResponse; + if (isSignInPage(request) && (await convexAuth.isAuthenticated())) { + return nextjsMiddlewareRedirect(request, '/'); + } + if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) { + return nextjsMiddlewareRedirect(request, '/sign-in'); + } + }, + { cookieConfig: { maxAge: 60 * 60 * 24 * 30 } }, +); + +export const config = { + // The following matcher runs middleware on all routes + // except static assets. + matcher: [ + '/((?!_next/static|_next/image|favicon.ico|monitoring-tunnel|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', + '/((?!.*\\..*|_next).*)', + '/', + '/(api|trpc)(.*)', + ], +}; diff --git a/bun.lock b/bun.lock index 0bd3575..129a43c 100644 --- a/bun.lock +++ b/bun.lock @@ -1,12 +1,12 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "convex-turbo", "devDependencies": { "@gib/prettier-config": "workspace:", "@turbo/gen": "^2.7.3", + "baseline-browser-mapping": "^2.9.14", "dotenv-cli": "^10.0.0", "prettier": "catalog:", "turbo": "^2.7.3", @@ -91,8 +91,8 @@ "@types/node": "catalog:", "@types/react": "catalog:react19", "@types/react-dom": "catalog:react19", + "baseline-browser-mapping": "^2.9.14", "eslint": "catalog:", - "jiti": "^2.5.1", "prettier": "catalog:", "tailwindcss": "catalog:", "tw-animate-css": "^1.4.0", @@ -240,7 +240,7 @@ "react19": { "@types/react": "~19.1.0", "@types/react-dom": "~19.1.0", - "react": "^19.1.4", + "react": "19.1.4", "react-dom": "19.1.4", }, }, @@ -1605,7 +1605,7 @@ "base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.8.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.14", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg=="], "basic-ftp": ["basic-ftp@5.0.5", "", {}, "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg=="], @@ -2689,7 +2689,7 @@ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], - "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], + "react": ["react@19.1.4", "", {}, "sha512-DHINL3PAmPUiK1uszfbKiXqfE03eszdt5BpVSuEAHb5nfmNPwnsy7g39h2t8aXFc/Bv99GH81s+j8dobtD+jOw=="], "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], @@ -3397,6 +3397,8 @@ "better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + "browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.8.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q=="], + "builtins/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "chalk-template/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -3623,6 +3625,8 @@ "update-browserslist-db/picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "usesend-js/react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], + "webpack/eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], "webpack/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], diff --git a/package.json b/package.json index b891391..ec3dfc4 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "react19": { "@types/react": "~19.1.0", "@types/react-dom": "~19.1.0", - "react": "^19.1.4", + "react": "19.1.4", "react-dom": "19.1.4" } }, @@ -56,6 +56,7 @@ "devDependencies": { "@gib/prettier-config": "workspace:", "@turbo/gen": "^2.7.3", + "baseline-browser-mapping": "^2.9.14", "dotenv-cli": "^10.0.0", "prettier": "catalog:", "turbo": "^2.7.3", diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index b74dda9..6440f2c 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -8,15 +8,18 @@ * @module */ +import type * as auth from "../auth.js"; +import type * as custom_auth_index from "../custom/auth/index.js"; +import type * as custom_auth_providers_password from "../custom/auth/providers/password.js"; +import type * as custom_auth_providers_usesend from "../custom/auth/providers/usesend.js"; +import type * as http from "../http.js"; +import type * as utils from "../utils.js"; + import type { ApiFromModules, FilterApi, FunctionReference, -} from 'convex/server'; - -import type * as notes from '../notes.js'; -import type * as openai from '../openai.js'; -import type * as utils from '../utils.js'; +} from "convex/server"; /** * A utility for referencing Convex functions in your app's API. @@ -27,15 +30,22 @@ import type * as utils from '../utils.js'; * ``` */ declare const fullApi: ApiFromModules<{ - notes: typeof notes; - openai: typeof openai; + auth: typeof auth; + "custom/auth/index": typeof custom_auth_index; + "custom/auth/providers/password": typeof custom_auth_providers_password; + "custom/auth/providers/usesend": typeof custom_auth_providers_usesend; + http: typeof http; utils: typeof utils; }>; +declare const fullApiWithMounts: typeof fullApi; + export declare const api: FilterApi< - typeof fullApi, - FunctionReference + typeof fullApiWithMounts, + FunctionReference >; export declare const internal: FilterApi< - typeof fullApi, - FunctionReference + typeof fullApiWithMounts, + FunctionReference >; + +export declare const components: {}; diff --git a/packages/backend/convex/_generated/api.js b/packages/backend/convex/_generated/api.js index 2e31a22..44bf985 100644 --- a/packages/backend/convex/_generated/api.js +++ b/packages/backend/convex/_generated/api.js @@ -8,7 +8,7 @@ * @module */ -import { anyApi } from 'convex/server'; +import { anyApi, componentsGeneric } from "convex/server"; /** * A utility for referencing Convex functions in your app's API. @@ -20,3 +20,4 @@ import { anyApi } from 'convex/server'; */ export const api = anyApi; export const internal = anyApi; +export const components = componentsGeneric(); diff --git a/packages/backend/convex/_generated/dataModel.d.ts b/packages/backend/convex/_generated/dataModel.d.ts index bebbb31..8541f31 100644 --- a/packages/backend/convex/_generated/dataModel.d.ts +++ b/packages/backend/convex/_generated/dataModel.d.ts @@ -11,12 +11,11 @@ import type { DataModelFromSchemaDefinition, DocumentByName, - SystemTableNames, TableNamesInDataModel, -} from 'convex/server'; -import type { GenericId } from 'convex/values'; - -import schema from '../schema.js'; + SystemTableNames, +} from "convex/server"; +import type { GenericId } from "convex/values"; +import schema from "../schema.js"; /** * The names of all of your Convex tables. diff --git a/packages/backend/convex/_generated/server.d.ts b/packages/backend/convex/_generated/server.d.ts index 2403186..b5c6828 100644 --- a/packages/backend/convex/_generated/server.d.ts +++ b/packages/backend/convex/_generated/server.d.ts @@ -10,17 +10,23 @@ import { ActionBuilder, - GenericActionCtx, - GenericDatabaseReader, - GenericDatabaseWriter, - GenericMutationCtx, - GenericQueryCtx, + AnyComponents, HttpActionBuilder, MutationBuilder, QueryBuilder, -} from 'convex/server'; + GenericActionCtx, + GenericMutationCtx, + GenericQueryCtx, + GenericDatabaseReader, + GenericDatabaseWriter, + FunctionReference, +} from "convex/server"; +import type { DataModel } from "./dataModel.js"; -import type { DataModel } from './dataModel.js'; +type GenericCtx = + | GenericActionCtx + | GenericMutationCtx + | GenericQueryCtx; /** * Define a query in this Convex app's public API. @@ -30,7 +36,7 @@ import type { DataModel } from './dataModel.js'; * @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @returns The wrapped query. Include this as an `export` to name it and make it accessible. */ -export declare const query: QueryBuilder; +export declare const query: QueryBuilder; /** * Define a query that is only accessible from other Convex functions (but not from the client). @@ -40,7 +46,7 @@ export declare const query: QueryBuilder; * @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @returns The wrapped query. Include this as an `export` to name it and make it accessible. */ -export declare const internalQuery: QueryBuilder; +export declare const internalQuery: QueryBuilder; /** * Define a mutation in this Convex app's public API. @@ -50,7 +56,7 @@ export declare const internalQuery: QueryBuilder; * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. */ -export declare const mutation: MutationBuilder; +export declare const mutation: MutationBuilder; /** * Define a mutation that is only accessible from other Convex functions (but not from the client). @@ -60,7 +66,7 @@ export declare const mutation: MutationBuilder; * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. */ -export declare const internalMutation: MutationBuilder; +export declare const internalMutation: MutationBuilder; /** * Define an action in this Convex app's public API. @@ -73,7 +79,7 @@ export declare const internalMutation: MutationBuilder; * @param func - The action. It receives an {@link ActionCtx} as its first argument. * @returns The wrapped action. Include this as an `export` to name it and make it accessible. */ -export declare const action: ActionBuilder; +export declare const action: ActionBuilder; /** * Define an action that is only accessible from other Convex functions (but not from the client). @@ -81,7 +87,7 @@ export declare const action: ActionBuilder; * @param func - The function. It receives an {@link ActionCtx} as its first argument. * @returns The wrapped function. Include this as an `export` to name it and make it accessible. */ -export declare const internalAction: ActionBuilder; +export declare const internalAction: ActionBuilder; /** * Define an HTTP action. diff --git a/packages/backend/convex/_generated/server.js b/packages/backend/convex/_generated/server.js index e81ccfd..4a21df4 100644 --- a/packages/backend/convex/_generated/server.js +++ b/packages/backend/convex/_generated/server.js @@ -11,12 +11,13 @@ import { actionGeneric, httpActionGeneric, + queryGeneric, + mutationGeneric, internalActionGeneric, internalMutationGeneric, internalQueryGeneric, - mutationGeneric, - queryGeneric, -} from 'convex/server'; + componentsGeneric, +} from "convex/server"; /** * Define a query in this Convex app's public API. diff --git a/packages/backend/package.json b/packages/backend/package.json index 6cca95d..7fb0193 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -6,15 +6,18 @@ "description": "Convex Backend for Monorepo", "author": "Gib", "license": "MIT", - "exports": {}, + "exports": { + "./types" : "./types/index.ts" + }, "scripts": { - "dev": "convex dev", - "dev:tunnel": "convex dev", - "setup": "convex dev --until-success", + "dev": "bun with-env convex dev", + "dev:tunnel": "bun with-env convex dev", + "setup": "bun with-env convex dev --until-success", "clean": "git clean -xdf .cache .turbo dist node_modules", "format": "prettier --check . --ignore-path ../../.gitignore", "lint": "eslint --flag unstable_native_nodejs_ts_config", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "with-env": "dotenv -e ../../.env --" }, "dependencies": { "@oslojs/crypto": "^1.0.1", diff --git a/packages/backend/scripts/generateKeys.mjs b/packages/backend/scripts/generateKeys.mjs new file mode 100755 index 0000000..2116061 --- /dev/null +++ b/packages/backend/scripts/generateKeys.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node + +import { exportJWK, exportPKCS8, generateKeyPair } from "jose"; + +const keys = await generateKeyPair("RS256", { + extractable: true, +}); +const privateKey = await exportPKCS8(keys.privateKey); +const publicKey = await exportJWK(keys.publicKey); +const jwks = JSON.stringify({ keys: [{ use: "sig", ...publicKey }] }); + +process.stdout.write( + `JWT_PRIVATE_KEY="${privateKey.trimEnd().replace(/\n/g, " ")}"`, +); +process.stdout.write("\n"); +process.stdout.write(`JWKS=${jwks}`); +process.stdout.write("\n"); diff --git a/packages/backend/types/auth.ts b/packages/backend/types/auth.ts new file mode 100644 index 0000000..287aa01 --- /dev/null +++ b/packages/backend/types/auth.ts @@ -0,0 +1,4 @@ +export const PASSWORD_MIN = 8; +export const PASSWORD_MAX = 100; +export const PASSWORD_REGEX = + /^(?=.{8,100}$)(?!.*\s)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\p{P}\p{S}]).*$/u; diff --git a/packages/backend/types/index.ts b/packages/backend/types/index.ts new file mode 100644 index 0000000..0ac8378 --- /dev/null +++ b/packages/backend/types/index.ts @@ -0,0 +1,5 @@ +export { + PASSWORD_MIN, + PASSWORD_MAX, + PASSWORD_REGEX, +} from './auth';