17 KiB
Hackerspace.sh - Required Fixes for Production
Last Updated: 2026-03-10
Priority: CRITICAL > HIGH > MEDIUM > LOW
This document provides a prioritized, actionable checklist of all required fixes.
CRITICAL Fixes (Block Production)
1. Add Input Validation
Files: lib/actions.ts
Action: Add Zod schemas to all server actions.
pnpm add zod
Example Implementation:
// lib/schemas.ts
import { z } from 'zod'
export const createTaskSchema = z.object({
title: z.string().min(1, 'Title required').max(200),
description: z.string().max(2000).optional(),
type: z.enum(['task', 'chore']),
area: z.string().max(50).optional(),
recurrence: z.enum(['none', 'daily', 'weekly', 'biweekly', 'monthly']).default('none'),
due_date: z.string().optional(),
})
export const createProjectSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
area: z.string().max(50).optional(),
tags: z.array(z.string().max(30)).max(10).optional(),
due_date: z.string().optional(),
})
export const addMemberSchema = z.object({
display_name: z.string().min(1).max(100),
email: z.string().email(),
phone: z.string().max(20).optional(),
handle: z.string().max(50).optional(),
tier: z.enum(['plus', 'basic', 'associate']),
role: z.enum(['admin', 'board', 'treasurer', 'member', 'associate']),
joined_at: z.string().optional(),
has_card_access: z.boolean().optional(),
})
// Add schemas for all other actions...
Update actions.ts:
import { createTaskSchema } from './schemas'
export async function createTask(formData: unknown) {
const parsed = createTaskSchema.safeParse(formData)
if (!parsed.success) {
return { error: 'Invalid input', details: parsed.error.flatten() }
}
const { title, description, type, area, recurrence, due_date } = parsed.data
// Continue with validated data...
}
2. Implement Rate Limiting
Files: middleware.ts, new lib/ratelimit.ts
Action: Add Upstash rate limiting.
pnpm add @upstash/ratelimit @upstash/redis
Environment Variables:
UPSTASH_REDIS_REST_URL=your-url
UPSTASH_REDIS_REST_TOKEN=your-token
Implementation:
// lib/ratelimit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
export const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(60, '1 m'), // 60 requests per minute
analytics: true,
})
export const authRatelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 auth attempts per minute
})
Update middleware.ts:
import { ratelimit } from '@/lib/ratelimit'
export async function middleware(request: NextRequest) {
const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? '127.0.0.1'
const { success, limit, remaining, reset } = await ratelimit.limit(ip)
if (!success) {
return new NextResponse('Too Many Requests', {
status: 429,
headers: {
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': reset.toString(),
'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
},
})
}
// Continue with existing middleware...
}
3. Encrypt Secrets
Files: New lib/encryption.ts, update lib/actions.ts
Environment Variables:
SECRETS_ENCRYPTION_KEY=your-32-byte-hex-key
Generate key: openssl rand -hex 32
Implementation:
// lib/encryption.ts
import crypto from 'crypto'
const ALGORITHM = 'aes-256-gcm'
const IV_LENGTH = 16
const AUTH_TAG_LENGTH = 16
export function encrypt(text: string): string {
const key = Buffer.from(process.env.SECRETS_ENCRYPTION_KEY!, 'hex')
const iv = crypto.randomBytes(IV_LENGTH)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
let encrypted = cipher.update(text, 'utf8', 'hex')
encrypted += cipher.final('hex')
const authTag = cipher.getAuthTag().toString('hex')
return `${iv.toString('hex')}:${authTag}:${encrypted}`
}
export function decrypt(encrypted: string): string {
const key = Buffer.from(process.env.SECRETS_ENCRYPTION_KEY!, 'hex')
const [ivHex, authTagHex, encryptedText] = encrypted.split(':')
const decipher = crypto.createDecipheriv(
ALGORITHM,
key,
Buffer.from(ivHex, 'hex')
)
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
let decrypted = decipher.update(encryptedText, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}
Update createSecret action:
import { encrypt } from './encryption'
export async function createSecret(formData: { /* ... */ }) {
// ... validation and auth checks ...
const { data, error } = await supabase.from('secrets').insert({
space_id: member.space_id,
title: formData.title,
value: encrypt(formData.value), // Encrypt before storing
description: formData.description,
// ...
})
}
4. Add Error Boundaries
Files: New error handling files
Create app/(app)/error.tsx:
'use client'
import { useEffect } from 'react'
import { Button } from '@/components/ui/button'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Log to error reporting service
console.error('[v0] App error:', error)
}, [error])
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center max-w-md px-4">
<h2 className="text-xl font-semibold text-foreground mb-2">
Something went wrong
</h2>
<p className="text-muted-foreground mb-4 text-sm">
We encountered an unexpected error. Please try again.
</p>
{process.env.NODE_ENV === 'development' && (
<pre className="text-xs text-red-500 bg-red-50 p-2 rounded mb-4 text-left overflow-auto">
{error.message}
</pre>
)}
<Button onClick={reset}>Try again</Button>
</div>
</div>
)
}
Create app/(app)/loading.tsx:
import { Spinner } from '@/components/ui/spinner'
export default function Loading() {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<Spinner className="w-8 h-8 text-primary" />
</div>
)
}
Create app/error.tsx and app/loading.tsx with similar patterns.
5. Add CSRF/Origin Check
Files: lib/actions.ts
Add helper function:
import { headers } from 'next/headers'
function verifyOrigin() {
const headersList = headers()
const origin = headersList.get('origin')
const host = headersList.get('host')
// In production, verify origin matches host
if (process.env.NODE_ENV === 'production') {
if (origin && host && !origin.includes(host)) {
throw new Error('Invalid origin')
}
}
}
Add to sensitive actions:
export async function deleteSecret(secretId: string) {
verifyOrigin()
// ... rest of action
}
export async function removeMember(memberId: string) {
verifyOrigin()
// ... rest of action
}
HIGH Priority Fixes
6. Wire Social Auth
File: app/login/page.tsx
async function handleGitHubLogin() {
const supabase = createClient()
const { error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
})
if (error) setError(error.message)
}
async function handleGoogleLogin() {
const supabase = createClient()
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
queryParams: {
access_type: 'offline',
prompt: 'consent',
},
},
})
if (error) setError(error.message)
}
Update auth callback (already exists but verify):
// app/auth/callback/route.ts
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/dashboard'
if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${next}`)
}
}
return NextResponse.redirect(`${origin}/login?error=auth_failed`)
}
Supabase Dashboard: Enable GitHub and Google providers, add credentials.
7. Implement CSV Import
File: app/(app)/import/page.tsx - convert to client component
pnpm add papaparse
pnpm add -D @types/papaparse
Implementation:
'use client'
import { useState, useRef } from 'react'
import Papa from 'papaparse'
import { importMembers } from '@/lib/actions'
export default function ImportPage() {
const [file, setFile] = useState<File | null>(null)
const [preview, setPreview] = useState<any[]>([])
const [mapping, setMapping] = useState<Record<string, string>>({})
const [step, setStep] = useState<'upload' | 'map' | 'preview' | 'done'>('upload')
const [importing, setImporting] = useState(false)
const [result, setResult] = useState<{ count?: number; error?: string } | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const appFields = ['display_name', 'email', 'phone', 'tier', 'joined_at', 'last_paid_at', 'has_card_access']
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const selectedFile = e.target.files?.[0]
if (!selectedFile) return
setFile(selectedFile)
Papa.parse(selectedFile, {
header: true,
preview: 5,
complete: (results) => {
setPreview(results.data)
// Auto-map matching columns
const headers = results.meta.fields || []
const autoMap: Record<string, string> = {}
headers.forEach(header => {
const lower = header.toLowerCase().replace(/[^a-z]/g, '')
if (lower.includes('name')) autoMap[header] = 'display_name'
else if (lower.includes('email')) autoMap[header] = 'email'
else if (lower.includes('phone')) autoMap[header] = 'phone'
else if (lower.includes('tier')) autoMap[header] = 'tier'
else if (lower.includes('join')) autoMap[header] = 'joined_at'
else if (lower.includes('paid') || lower.includes('dues')) autoMap[header] = 'last_paid_at'
else if (lower.includes('card')) autoMap[header] = 'has_card_access'
})
setMapping(autoMap)
setStep('map')
},
error: (error) => setResult({ error: error.message }),
})
}
async function handleImport() {
if (!file) return
setImporting(true)
Papa.parse(file, {
header: true,
complete: async (results) => {
const mappedRows = results.data.map((row: any) => {
const mapped: any = {}
Object.entries(mapping).forEach(([csvCol, appField]) => {
if (appField && row[csvCol]) {
mapped[appField] = row[csvCol]
}
})
return mapped
}).filter((row: any) => row.display_name && row.email)
const result = await importMembers(mappedRows)
setResult(result)
setStep('done')
setImporting(false)
},
})
}
// ... render UI based on step
}
8. Create Webhook Endpoint
File: New app/api/webhook/[slug]/members/route.ts
import { createAdminClient } from '@/lib/supabase/admin'
import { headers } from 'next/headers'
import crypto from 'crypto'
export async function POST(
request: Request,
{ params }: { params: { slug: string } }
) {
const headersList = headers()
const signature = headersList.get('x-webhook-signature')
const timestamp = headersList.get('x-webhook-timestamp')
// Verify timestamp is recent (prevent replay attacks)
if (timestamp) {
const age = Date.now() - parseInt(timestamp)
if (age > 300000) { // 5 minutes
return Response.json({ error: 'Request expired' }, { status: 401 })
}
}
const admin = createAdminClient()
// Get space and verify it exists
const { data: space } = await admin
.from('spaces')
.select('id, webhook_secret')
.eq('slug', params.slug)
.single()
if (!space) {
return Response.json({ error: 'Space not found' }, { status: 404 })
}
if (!space.webhook_secret) {
return Response.json({ error: 'Webhook not configured' }, { status: 400 })
}
// Verify signature
const body = await request.text()
const expectedSignature = crypto
.createHmac('sha256', space.webhook_secret)
.update(`${timestamp}.${body}`)
.digest('hex')
if (signature !== `sha256=${expectedSignature}`) {
return Response.json({ error: 'Invalid signature' }, { status: 401 })
}
// Process payload
try {
const data = JSON.parse(body)
// Handle different event types
switch (data.event) {
case 'member.create':
await admin.from('space_members').insert({
space_id: space.id,
display_name: data.member.name,
email: data.member.email,
// ... other fields
})
break
case 'member.update':
await admin.from('space_members').update({
display_name: data.member.name,
// ... other fields
}).eq('space_id', space.id).eq('email', data.member.email)
break
// ... other events
}
return Response.json({ success: true })
} catch (error) {
return Response.json({ error: 'Invalid payload' }, { status: 400 })
}
}
MEDIUM Priority Fixes
9. Add Email Notifications
Use Resend or similar service:
pnpm add resend
// lib/email.ts
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)
export async function sendMemberApprovalEmail(
to: string,
spaceName: string
) {
await resend.emails.send({
from: '[email protected]',
to,
subject: `Welcome to ${spaceName}!`,
html: `<p>Your membership has been approved...</p>`,
})
}
export async function sendTaskAssignmentEmail(
to: string,
taskTitle: string,
spaceName: string
) {
await resend.emails.send({
from: '[email protected]',
to,
subject: `New task assigned: ${taskTitle}`,
html: `<p>You have been assigned a task in ${spaceName}...</p>`,
})
}
10. Add Test Suite
pnpm add -D vitest @testing-library/react @testing-library/jest-dom @vitejs/plugin-react jsdom
vitest.config.ts:
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
globals: true,
},
resolve: {
alias: {
'@': resolve(__dirname, './'),
},
},
})
tests/setup.ts:
import '@testing-library/jest-dom'
Example test:
// tests/lib/actions.test.ts
import { describe, it, expect, vi } from 'vitest'
describe('Server Actions', () => {
describe('createTask', () => {
it('requires authentication', async () => {
// Mock unauthenticated user
// ...
})
it('validates input', async () => {
// Test with invalid data
// ...
})
it('creates task successfully', async () => {
// Test happy path
// ...
})
})
})
Quick Reference: All Files to Create/Update
New Files
lib/schemas.ts- Zod schemaslib/ratelimit.ts- Rate limitinglib/encryption.ts- Secrets encryptionlib/email.ts- Email sendingapp/(app)/error.tsx- Error boundaryapp/(app)/loading.tsx- Loading stateapp/error.tsx- Root error boundaryapp/loading.tsx- Root loading stateapp/api/webhook/[slug]/members/route.ts- Webhook endpointvitest.config.ts- Test configtests/setup.ts- Test setuptests/**/*.test.ts- Test files
Files to Update
lib/actions.ts- Add validation, origin checksmiddleware.ts- Add rate limitingapp/login/page.tsx- Wire social authapp/(app)/import/page.tsx- Implement CSV processing
Dependencies to Add
{
"dependencies": {
"zod": "^3.x",
"@upstash/ratelimit": "^1.x",
"@upstash/redis": "^1.x",
"papaparse": "^5.x",
"resend": "^3.x"
},
"devDependencies": {
"@types/papaparse": "^5.x",
"vitest": "^1.x",
"@testing-library/react": "^14.x",
"@testing-library/jest-dom": "^6.x",
"@vitejs/plugin-react": "^4.x",
"jsdom": "^24.x"
}
}
Environment Variables to Add
# Rate Limiting
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
# Encryption
SECRETS_ENCRYPTION_KEY=
# Email
RESEND_API_KEY=
# OAuth (configure in Supabase)
# GitHub and Google credentials set in Supabase Dashboard