Update Convex with no payload to be just like convex with payload but without payload

This commit is contained in:
Gabriel Brown
2026-06-21 15:35:42 -05:00
parent 13b8b36c4c
commit fba73a92ce
130 changed files with 15637 additions and 32018 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+15 -6
View File
@@ -5,24 +5,28 @@
"private": true,
"scripts": {
"build": "bun with-env next build",
"build:env": "bun with-env next build",
"build:docker": "next build --webpack",
"clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "bun with-env next dev --turbo",
"dev:tunnel": "bun with-env next dev --turbo",
"dev:web": "bun with-env next dev --webpack",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint --flag unstable_native_nodejs_ts_config",
"start": "bun with-env next start",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
"test:unit": "NODE_ENV=test vitest run --project unit",
"test:integration": "NODE_ENV=test vitest run --project integration",
"test:component": "NODE_ENV=test vitest run --project component",
"with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --"
},
"dependencies": {
"@convex-dev/auth": "catalog:convex",
"@gib/backend": "workspace:*",
"@gib/ui": "workspace:*",
"@sentry/nextjs": "^10.43.0",
"@t3-oss/env-nextjs": "^0.13.10",
"@sentry/nextjs": "^10.46.0",
"@t3-oss/env-nextjs": "^0.13.11",
"convex": "catalog:convex",
"next": "^16.1.7",
"next": "^16.2.1",
"next-plausible": "^3.12.5",
"react": "catalog:react19",
"react-dom": "catalog:react19",
@@ -35,14 +39,19 @@
"@gib/prettier-config": "workspace:*",
"@gib/tailwind-config": "workspace:*",
"@gib/tsconfig": "workspace:*",
"@gib/vitest-config": "workspace:*",
"@testing-library/react": "catalog:test",
"@types/node": "catalog:",
"@types/react": "catalog:react19",
"@types/react-dom": "catalog:react19",
"baseline-browser-mapping": "^2.10.11",
"eslint": "catalog:",
"jsdom": "catalog:test",
"prettier": "catalog:",
"tailwindcss": "catalog:",
"tw-animate-css": "^1.4.0",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "catalog:test"
},
"prettier": "@gib/prettier-config"
}
+12 -1
View File
@@ -1,14 +1,25 @@
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { isAuthenticatedNextjs } from '@convex-dev/auth/nextjs/server';
export const generateMetadata = (): Metadata => {
return {
title: 'Profile',
robots: {
index: false,
follow: false,
googleBot: {
index: false,
follow: false,
},
},
};
};
const ProfileLayout = ({
const ProfileLayout = async ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
if (!(await isAuthenticatedNextjs())) redirect('/sign-in');
return <>{children}</>;
};
export default ProfileLayout;
+2 -2
View File
@@ -159,7 +159,7 @@ const SignIn = () => {
};
const handleVerifyEmail = async (
values: z.infer<typeof verifyEmailFormSchema>,
_values: z.infer<typeof verifyEmailFormSchema>,
) => {
const formData = new FormData();
formData.append('code', code);
@@ -194,7 +194,7 @@ const SignIn = () => {
<FormField
control={verifyEmailForm.control}
name='code'
render={({ field }) => (
render={({ field: _field }) => (
<FormItem>
<FormLabel className='text-xl'>Code</FormLabel>
<FormControl>
+10 -10
View File
@@ -1,12 +1,12 @@
import { CTA, Features, Hero, TechStack } from '@/components/landing';
export default function Home() {
return (
<main className='flex min-h-screen flex-col'>
<Hero />
<Features />
<TechStack />
<CTA />
</main>
);
}
const Home = () => (
<main className='flex min-h-screen flex-col'>
<Hero />
<Features />
<TechStack />
<CTA />
</main>
);
export default Home;
+5 -1
View File
@@ -2,7 +2,7 @@
@import 'tw-animate-css';
@import '@gib/tailwind-config/theme';
@source '../../../../packages/ui/src/*.{ts,tsx}';
@source '../../../../../packages/ui/src/*.{ts,tsx}';
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant light (&:where(.light, .light *));
@@ -23,7 +23,11 @@
* {
@apply border-border;
}
html {
@apply bg-background;
}
body {
@apply bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
}
-1
View File
@@ -10,7 +10,6 @@ export const CTA = () => (
everything pre-configured.
</p>
{/* Quick Start Command */}
<div className='mt-12'>
<p className='text-muted-foreground mb-3 text-sm font-medium'>
Quick Start
@@ -60,7 +60,6 @@ const features = [
export const Features = () => (
<section id='features' className='container mx-auto px-4 py-24'>
<div className='mx-auto max-w-6xl'>
{/* Section Header */}
<div className='mb-16 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
Everything You Need to Ship Fast
@@ -71,7 +70,6 @@ export const Features = () => (
</p>
</div>
{/* Features Grid */}
<div className='grid gap-6 md:grid-cols-2 lg:grid-cols-3'>
{features.map((feature) => (
<Card key={feature.title} className='border-border/40'>
+20 -69
View File
@@ -9,16 +9,16 @@ const kanitSans = Kanit({
weight: ['400', '500', '600', '700'],
});
const highlights = ['TypeScript', 'Self-Hosted', 'Real-time', 'Auth Included'];
export const Hero = () => (
<section className='container mx-auto px-4 py-24 md:py-32 lg:py-40'>
<div className='mx-auto flex max-w-5xl flex-col items-center gap-8 text-center'>
{/* Badge */}
<div className='border-border/40 bg-muted/50 inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium'>
<span className='mr-2'>🚀</span>
<span>Production-ready monorepo template</span>
</div>
{/* Heading */}
<h1 className='from-foreground to-foreground/70 bg-linear-to-br bg-clip-text text-4xl font-bold tracking-tight text-transparent sm:text-5xl md:text-6xl lg:text-7xl'>
Build Full-Stack Apps with{' '}
<span
@@ -28,14 +28,12 @@ export const Hero = () => (
</span>
</h1>
{/* Description */}
<p className='text-muted-foreground max-w-2xl text-lg md:text-xl'>
A Turborepo starter with Next.js, Expo, and self-hosted Convex. Ship web
and mobile apps faster with shared code, type-safe backend, and complete
control over your infrastructure.
</p>
{/* CTA Buttons */}
<div className='flex flex-col gap-3 sm:flex-row'>
<Button size='lg' variant='outline' asChild>
<Link
@@ -54,72 +52,25 @@ export const Hero = () => (
</Button>
</div>
{/* Features Quick List */}
<div className='text-muted-foreground mt-8 flex flex-wrap items-center justify-center gap-6 text-sm'>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>TypeScript</span>
</div>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>Self-Hosted</span>
</div>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>Real-time</span>
</div>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>Auth Included</span>
</div>
{highlights.map((highlight) => (
<div key={highlight} className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>{highlight}</span>
</div>
))}
</div>
</div>
</section>
@@ -29,7 +29,7 @@ const techStack = [
technologies: [
{ name: 'Turborepo', description: 'High-performance build system' },
{ name: 'TypeScript', description: 'Type-safe development' },
{ name: 'Bun', description: 'Fast package manager & runtime' },
{ name: 'Bun', description: 'Fast package manager and runtime' },
{ name: 'ESLint + Prettier', description: 'Code quality tools' },
{ name: 'Docker', description: 'Containerized deployment' },
],
@@ -40,7 +40,6 @@ export const TechStack = () => (
<section id='tech-stack' className='border-border/40 bg-muted/30 border-t'>
<div className='container mx-auto px-4 py-24'>
<div className='mx-auto max-w-6xl'>
{/* Section Header */}
<div className='mb-16 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
Modern Tech Stack
@@ -51,7 +50,6 @@ export const TechStack = () => (
</p>
</div>
{/* Tech Stack Grid */}
<div className='grid gap-12 md:grid-cols-3'>
{techStack.map((stack) => (
<div key={stack.category}>
@@ -21,9 +21,9 @@ import {
Input,
} from '@gib/ui';
interface AvatarUploadProps {
type AvatarUploadProps = {
preloadedUser: Preloaded<typeof api.auth.getUser>;
}
};
const dataUrlToBlob = async (
dataUrl: string,
@@ -1,17 +1,54 @@
'use client';
import type { ComponentProps } from 'react';
import { Kanit } from 'next/font/google';
import Image from 'next/image';
import Link from 'next/link';
import { Coffee, Server, Wrench } from 'lucide-react';
import { useConvexAuth, useQuery } from 'convex/react';
import { Coffee, Server, User, Wrench } from 'lucide-react';
import { api } from '@gib/backend/convex/_generated/api.js';
import type { NavItem } from './navigation';
import { Controls } from './controls';
import { DesktopNavigation, MobileNavigation } from './navigation';
const kanitSans = Kanit({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
});
export default function Header(headerProps: ComponentProps<'header'>) {
const Header = (headerProps: ComponentProps<'header'>) => {
const { isAuthenticated } = useConvexAuth();
const user = useQuery(api.auth.getUser, isAuthenticated ? {} : 'skip');
const navItems: NavItem[] = [
{
href: '/#features',
icon: Wrench,
label: 'Features',
},
{
href: '/#tech-stack',
icon: Server,
label: 'Stack',
},
{
href: 'https://git.gbrown.org/gib/convex-monorepo',
icon: Coffee,
label: 'Repository',
external: true,
},
];
if (user?.isAdmin) {
navItems.push({
href: '/admin',
icon: User,
label: 'Admin',
external: true,
});
}
return (
<header
className='border-border/40 bg-background/95 supports-backdrop-filter:bg-background/60 sticky top-0 z-50 w-full border-b backdrop-blur'
@@ -28,45 +65,24 @@ export default function Header(headerProps: ComponentProps<'header'>) {
alt='Convex Monorepo'
width={50}
height={50}
className='w-15 invert dark:invert-0'
className='w-10 invert lg:w-15 dark:invert-0'
/>
<span
className={`mb-3 hidden font-extrabold lg:inline lg:text-5xl ${kanitSans.className}`}
className={`hidden font-extrabold sm:mb-1 sm:text-lg md:inline lg:mb-3 lg:text-4xl xl:text-5xl ${kanitSans.className}`}
>
convex monorepo
convex-monorepo
</span>
</Link>
{/* Navigation */}
<nav className='hidden items-center gap-6 text-base font-medium md:flex'>
<Link
href='/#features'
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
>
<Wrench width={18} height={18} />
Features
</Link>
<Link
href='/#tech-stack'
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
>
<Server width={18} height={18} />
Stack
</Link>
<Link
href='https://git.gbrown.org/gib/convex-monorepo'
target='_blank'
rel='noopener noreferrer'
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
>
<Coffee width={20} height={20} />
Repository
</Link>
</nav>
<DesktopNavigation items={navItems} />
{/* Controls (Theme + Auth) */}
<Controls />
<div className='flex items-center gap-2'>
<Controls />
<MobileNavigation items={navItems} />
</div>
</div>
</header>
);
}
};
export default Header;
@@ -0,0 +1,97 @@
'use client';
import type { LucideIcon } from 'lucide-react';
import Link from 'next/link';
import { ExternalLink, Menu } from 'lucide-react';
import {
Button,
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@gib/ui';
export type NavItem = {
href: string;
icon: LucideIcon;
label: string;
external?: boolean;
};
type NavigationProps = {
items: NavItem[];
};
const DesktopNavigation = ({ items }: NavigationProps) => {
return (
<nav className='hidden items-center gap-4 text-xs font-medium sm:flex md:gap-6 lg:text-base'>
{items.map(({ href, icon: Icon, label, external }) => (
<Link
key={label}
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
>
<Icon width={18} height={18} />
{label}
</Link>
))}
</nav>
);
};
const MobileNavigation = ({ items }: NavigationProps) => {
return (
<Sheet>
<SheetTrigger asChild>
<Button
variant='outline'
size='icon-sm'
className='sm:hidden'
aria-label='Open navigation menu'
>
<Menu className='size-4.5' />
<span className='sr-only'>Open navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side='right' className='w-[min(88vw,22rem)] px-0'>
<SheetHeader className='border-border/60 from-background to-muted/40 border-b bg-linear-to-br from-35% px-5 py-5 text-left'>
<SheetTitle className='text-left text-lg'>Navigation</SheetTitle>
<SheetDescription className='text-left'>
Quick access to the links that collapse out of the header.
</SheetDescription>
</SheetHeader>
<div className='flex flex-col gap-3 px-4 py-5'>
{items.map(({ href, icon: Icon, label, external }) => (
<SheetClose asChild key={label}>
<Link
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
className='bg-card hover:bg-muted/70 border-border/60 text-card-foreground flex items-center justify-between rounded-2xl border px-4 py-3 transition-colors'
>
<span className='flex items-center gap-3'>
<span className='bg-muted text-foreground flex h-9 w-9 items-center justify-center rounded-xl'>
<Icon className='size-4.5' />
</span>
<span className='text-sm font-medium'>{label}</span>
</span>
{external ? (
<ExternalLink className='text-muted-foreground size-4' />
) : null}
</Link>
</SheetClose>
))}
</div>
</SheetContent>
</Sheet>
);
};
export { DesktopNavigation, MobileNavigation };
+1 -1
View File
@@ -19,7 +19,7 @@ Sentry.init({
tracesSampleRate: 1,
enableLogs: true,
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
replaysSessionSampleRate: 1.0,
replaysSessionSampleRate: 0.5,
replaysOnErrorSampleRate: 1.0,
debug: false,
});
+38 -14
View File
@@ -4,7 +4,8 @@ import { NextResponse } from 'next/server';
// In-memory stores for tracking IPs (use Redis in production)
const ipAttempts = new Map<string, { count: number; lastAttempt: number }>();
const ip404Attempts = new Map<string, { count: number; lastAttempt: number }>();
const bannedIPs = new Set<string>();
// Map of ip -> ban expiry timestamp. Avoids setTimeout closures leaking on hot reload.
const bannedIPs = new Map<string, number>();
// Suspicious patterns that indicate malicious activity
const MALICIOUS_PATTERNS = [
@@ -72,6 +73,36 @@ const BAN_DURATION = 30 * 60 * 1000; // 30 minutes
const RATE_404_WINDOW = 2 * 60 * 1000; // 2 minutes
const MAX_404_ATTEMPTS = 10; // Max 404s before ban
let lastCleanup = Date.now();
const CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
// Lazily purge stale entries so Maps don't grow without bound.
// Called on every request but only iterates Maps every CLEANUP_INTERVAL.
const cleanupStaleMaps = () => {
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL) return;
lastCleanup = now;
for (const [ip, data] of ipAttempts.entries()) {
if (now - data.lastAttempt > RATE_LIMIT_WINDOW) ipAttempts.delete(ip);
}
for (const [ip, data] of ip404Attempts.entries()) {
if (now - data.lastAttempt > RATE_404_WINDOW) ip404Attempts.delete(ip);
}
for (const [ip, expiry] of bannedIPs.entries()) {
if (now > expiry) bannedIPs.delete(ip);
}
};
const isIPBanned = (ip: string): boolean => {
const expiry = bannedIPs.get(ip);
if (expiry === undefined) return false;
if (Date.now() > expiry) {
bannedIPs.delete(ip);
return false;
}
return true;
};
const getClientIP = (request: NextRequest): string => {
const forwarded = request.headers.get('x-forwarded-for');
const realIP = request.headers.get('x-real-ip');
@@ -104,13 +135,8 @@ const updateIPAttempts = (ip: string): boolean => {
attempts.lastAttempt = now;
if (attempts.count > MAX_ATTEMPTS) {
bannedIPs.add(ip);
bannedIPs.set(ip, Date.now() + BAN_DURATION);
ipAttempts.delete(ip);
setTimeout(() => {
bannedIPs.delete(ip);
}, BAN_DURATION);
return true;
}
@@ -130,17 +156,13 @@ const update404Attempts = (ip: string): boolean => {
attempts.lastAttempt = now;
if (attempts.count > MAX_404_ATTEMPTS) {
bannedIPs.add(ip);
bannedIPs.set(ip, Date.now() + BAN_DURATION);
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;
}
@@ -148,12 +170,14 @@ const update404Attempts = (ip: string): boolean => {
};
export const banSuspiciousIPs = (request: NextRequest): NextResponse | null => {
cleanupStaleMaps();
const { pathname } = request.nextUrl;
const method = request.method;
const ip = getClientIP(request);
// Check if IP is already banned
if (bannedIPs.has(ip)) {
if (isIPBanned(ip)) {
return new NextResponse('Access denied.', { status: 403 });
}
@@ -183,7 +207,7 @@ export const handle404Response = (
): NextResponse | null => {
const ip = getClientIP(request);
if (bannedIPs.has(ip)) {
if (isIPBanned(ip)) {
return new NextResponse('Access denied.', { status: 403 });
}
+21 -5
View File
@@ -1,19 +1,36 @@
import { convexAuthNextjsMiddleware } from '@convex-dev/auth/nextjs/server';
export default convexAuthNextjsMiddleware();
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|monitoring-tunnel|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
'/((?!.*\\..*|_next).*)',
'/',
'/(api)(.*)',
],
};
/**
import { banSuspiciousIPs } from '@/lib/proxy/ban-sus-ips';
import {
convexAuthNextjsMiddleware,
createRouteMatcher,
nextjsMiddlewareRedirect,
} from '@convex-dev/auth/nextjs/server';
const isSignInPage = createRouteMatcher(['/sign-in']);
const isProtectedRoute = createRouteMatcher(['/profile']);
const isProtectedRoute = createRouteMatcher([
'/profile(.*)',
'/dashboard(.*)',
'/admin-panel(.*)',
]);
export default convexAuthNextjsMiddleware(
async (request, { convexAuth }) => {
const banResponse = banSuspiciousIPs(request);
if (banResponse) return banResponse;
if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
return nextjsMiddlewareRedirect(request, '/');
return nextjsMiddlewareRedirect(request, '/profile');
}
if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
return nextjsMiddlewareRedirect(request, '/sign-in');
@@ -23,8 +40,6 @@ export default convexAuthNextjsMiddleware(
);
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).*)',
@@ -32,3 +47,4 @@ export const config = {
'/(api)(.*)',
],
};
**/
+11
View File
@@ -0,0 +1,11 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
const Greeting = ({ name }: { name: string }) => <p>Hello {name}</p>;
describe('component test harness', () => {
it('renders through jsdom and Testing Library', () => {
render(<Greeting name='world' />);
expect(screen.getByText('Hello world')).toBeInTheDocument();
});
});
@@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest';
describe('integration test harness', () => {
it('provides modern Node globals', () => {
expect(typeof globalThis.structuredClone).toBe('function');
});
});
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';
+7
View File
@@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest';
describe('unit test harness', () => {
it('executes isolated assertions', () => {
expect(1 + 1).toBe(2);
});
});
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
import { jsdomProject, nodeProject } from '@gib/vitest-config';
export default defineConfig({
test: {
projects: [
nodeProject('unit', ['tests/unit/**/*.test.{ts,tsx}']),
nodeProject('integration', ['tests/integration/**/*.test.{ts,tsx}']),
jsdomProject('component', ['tests/component/**/*.test.{ts,tsx}']),
],
},
});