Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ All notable changes to **JointSave** will be documented in this file.

### Added

- **React Error Boundaries** with fallback UIs and error reporting across all dashboard routes to prevent full page crashes.
- **Smart contract pause & recovery** capabilities to improve safety and operational resilience of deployed pool contracts.
- **Multi-token support** (including support for the native token) for pool participation.
- **Reputation tracking** to represent participant trust based on pool participation.
Expand Down
147 changes: 147 additions & 0 deletions frontend/app/api/errors/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* Unit tests for POST /api/errors route handler.
*
* Uses Node.js built-in test runner (node:test + node:assert) to match
* existing project test conventions.
*/
import { test, mock, beforeEach, before } from "node:test"
import assert from "node:assert"

// Set env vars so Supabase client initializes without throwing
process.env.NEXT_PUBLIC_SUPABASE_URL = "https://mock.supabase.co"
process.env.SUPABASE_SERVICE_ROLE_KEY = "mock-key"

let fetchCalls: Array<{ url: string; init: RequestInit }> = []
let shouldInsertFail = false

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let POST: any

before(async () => {
// Import the route handler AFTER setting env vars
const route = await import("./route")
POST = route.POST
})

beforeEach(() => {
fetchCalls = []
shouldInsertFail = false

globalThis.fetch = mock.fn(async (url: string | URL | Request, init?: RequestInit) => {
fetchCalls.push({ url: String(url), init: init! })

if (shouldInsertFail) {
return new Response(JSON.stringify({ error: { message: "DB insert failed" } }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}

return new Response(JSON.stringify([{ id: "mock-id" }]), {
status: 201,
headers: { "Content-Type": "application/json" },
})
}) as typeof globalThis.fetch
})

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function createRequest(body: unknown, headers?: Record<string, string>): Request {
return new Request("http://localhost:3000/api/errors", {
method: "POST",
headers: {
"Content-Type": "application/json",
...headers,
},
body: JSON.stringify(body),
})
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

test("POST /api/errors — returns 400 when message is missing", async () => {
const req = createRequest({ stack: "some stack" })
// Cast to NextRequest-compatible since the route expects NextRequest
const res = await POST(req as unknown as Parameters<typeof POST>[0])
assert.strictEqual(res.status, 400)
const json = await res.json()
assert.strictEqual(json.error, "VALIDATION_ERROR")
})

test("POST /api/errors — returns 400 for empty message", async () => {
const req = createRequest({ message: "" })
const res = await POST(req as unknown as Parameters<typeof POST>[0])
assert.strictEqual(res.status, 400)
})

test("POST /api/errors — returns 201 on valid payload", async () => {
const req = createRequest({
message: "Test component crashed",
stack: "Error: Test\n at Component",
walletAddress: "GABCDEF123",
sectionName: "Dashboard",
url: "http://localhost:3000/dashboard",
})
const res = await POST(req as unknown as Parameters<typeof POST>[0])
assert.strictEqual(res.status, 201)

const json = await res.json()
assert.strictEqual(json.ok, true)

// Verify Supabase insert was called via fetch
assert.strictEqual(fetchCalls.length, 1)
const inserted = JSON.parse(fetchCalls[0].init.body as string)
assert.strictEqual(inserted.job_name, "client_error")
assert.strictEqual(inserted.status, "failed")
assert.ok(
(inserted.error_message as string).includes("Test component crashed"),
"error_message should contain the original message"
)
})

test("POST /api/errors — includes section and wallet info in error_message", async () => {
const req = createRequest({
message: "RPC call failed",
walletAddress: "GXYZ789",
sectionName: "Yield Dashboard",
})
const res = await POST(req as unknown as Parameters<typeof POST>[0])
assert.strictEqual(res.status, 201)

const inserted = JSON.parse(fetchCalls[0].init.body as string)
const msg = inserted.error_message as string
assert.ok(msg.includes("Section: Yield Dashboard"))
assert.ok(msg.includes("Wallet: GXYZ789"))
})

test("POST /api/errors — returns 500 when Supabase insert fails", async () => {
shouldInsertFail = true
const req = createRequest({ message: "Some error" })
const res = await POST(req as unknown as Parameters<typeof POST>[0])
assert.strictEqual(res.status, 500)
const json = await res.json()
assert.strictEqual(json.error, "DB_ERROR")
})

test("POST /api/errors — rate-limits after 5 requests from same IP", async () => {
// Send 5 requests (should all succeed)
for (let i = 0; i < 5; i++) {
const req = createRequest({ message: `Error ${i}` }, { "x-forwarded-for": "192.168.1.100" })
const res = await POST(req as unknown as Parameters<typeof POST>[0])
assert.strictEqual(res.status, 201, `Request ${i + 1} should succeed`)
}

// 6th request should be rate-limited
const req = createRequest(
{ message: "Error 6 — should be blocked" },
{ "x-forwarded-for": "192.168.1.100" }
)
const res = await POST(req as unknown as Parameters<typeof POST>[0])
assert.strictEqual(res.status, 429)
const json = await res.json()
assert.strictEqual(json.error, "TOO_MANY_REQUESTS")
})
112 changes: 112 additions & 0 deletions frontend/app/api/errors/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from "next/server"
import { getAdminClient } from "@/lib/supabase-admin"

// ---------------------------------------------------------------------------
// Server-side rate limiter (sliding window, in-memory)
// ---------------------------------------------------------------------------

const MAX_REPORTS_PER_MINUTE = 5
const WINDOW_MS = 60_000

const rateLimitStore = new Map<string, number[]>()

function isServerRateLimited(key: string): boolean {
const now = Date.now()
let timestamps = rateLimitStore.get(key)
if (!timestamps) {
timestamps = []
rateLimitStore.set(key, timestamps)
}
// Prune old entries
while (timestamps.length > 0 && timestamps[0] <= now - WINDOW_MS) {
timestamps.shift()
}
if (timestamps.length >= MAX_REPORTS_PER_MINUTE) {
return true
}
timestamps.push(now)
return false
}

function resolveRateLimitKey(req: NextRequest, walletAddress?: string): string {
if (walletAddress) return `err:wallet:${walletAddress.toLowerCase()}`
const forwarded = req.headers.get("x-forwarded-for")
const ip = forwarded ? forwarded.split(",")[0].trim() : "unknown"
return `err:ip:${ip}`
}

// ---------------------------------------------------------------------------
// POST /api/errors
// ---------------------------------------------------------------------------

/**
* Receives client-side error reports from React Error Boundaries and logs
* them to the `cron_job_logs` Supabase table with `job_name: 'client_error'`.
*
* Rate-limited to 5 reports per minute per wallet/IP.
*/
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const { message, stack, componentStack, walletAddress, sectionName, url } = body as {
message?: string
stack?: string
componentStack?: string
walletAddress?: string
sectionName?: string
url?: string
}

if (!message || typeof message !== "string") {
return NextResponse.json(
{ error: "VALIDATION_ERROR", message: "Missing or invalid 'message' field." },
{ status: 400 }
)
}

// Rate limit
const key = resolveRateLimitKey(req, walletAddress)
if (isServerRateLimited(key)) {
return NextResponse.json(
{ error: "TOO_MANY_REQUESTS", message: "Error reporting rate limit exceeded." },
{ status: 429 }
)
}

// Build the error_message payload stored in cron_job_logs
const errorDetail = [
`[client_error] ${message}`,
sectionName ? `Section: ${sectionName}` : null,
walletAddress ? `Wallet: ${walletAddress}` : null,
url ? `URL: ${url}` : null,
stack ? `\nStack:\n${stack}` : null,
componentStack ? `\nComponent Stack:\n${componentStack}` : null,
]
.filter(Boolean)
.join("\n")

// Insert into cron_job_logs using the admin (service-role) client so
// RLS doesn't block the write.
const supabase = getAdminClient()
const { error: dbError } = await supabase.from("cron_job_logs").insert({
job_name: "client_error",
status: "failed",
error_message: errorDetail.slice(0, 4096), // cap to prevent oversized payloads
} as never)

if (dbError) {
console.error("[POST /api/errors] Supabase insert failed:", dbError)

Check warning on line 98 in frontend/app/api/errors/route.ts

View workflow job for this annotation

GitHub Actions / Frontend – Lint & Format Check

Unexpected console statement
return NextResponse.json(
{ error: "DB_ERROR", message: "Failed to log error." },
{ status: 500 }
)
}

return NextResponse.json({ ok: true }, { status: 201 })
} catch {
return NextResponse.json(
{ error: "INTERNAL_ERROR", message: "Unexpected server error." },
{ status: 500 }
)
}
}
55 changes: 29 additions & 26 deletions frontend/app/dashboard/create/[type]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button"
import { ArrowLeft } from "lucide-react"
import Link from "next/link"
import { redirect } from "next/navigation"
import { ErrorBoundary } from "@/components/error-boundary"

const validTypes = ["rotational", "target", "flexible"]

Expand Down Expand Up @@ -63,33 +64,35 @@ export default function CreateGroupPage({ params }: { params: Promise<{ type: st
}

return (
<div className="min-h-screen bg-background">
<DashboardHeader />
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="max-w-3xl mx-auto">
<Button variant="ghost" className="mb-6" asChild>
<Link href="/dashboard">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Dashboard
</Link>
</Button>
<ErrorBoundary sectionName="Create Pool">
<div className="min-h-screen bg-background">
<DashboardHeader />
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="max-w-3xl mx-auto">
<Button variant="ghost" className="mb-6" asChild>
<Link href="/dashboard">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Dashboard
</Link>
</Button>

<Card className="p-8">
<h1 className="text-3xl font-bold mb-2">
{prefill ? `New Cycle: ${prefill.name}` : titles[type as keyof typeof titles]}
</h1>
<p className="text-muted-foreground mb-8">
{prefill
? "Pre-filled from the original pool. Edit any values before creating."
: "Fill in the details to create your savings group"}
</p>
<Card className="p-8">
<h1 className="text-3xl font-bold mb-2">
{prefill ? `New Cycle: ${prefill.name}` : titles[type as keyof typeof titles]}
</h1>
<p className="text-muted-foreground mb-8">
{prefill
? "Pre-filled from the original pool. Edit any values before creating."
: "Fill in the details to create your savings group"}
</p>

{type === "rotational" && <RotationalForm prefill={prefill} />}
{type === "target" && <TargetForm prefill={prefill} />}
{type === "flexible" && <FlexibleForm prefill={prefill} />}
</Card>
</div>
</main>
</div>
{type === "rotational" && <RotationalForm prefill={prefill} />}
{type === "target" && <TargetForm prefill={prefill} />}
{type === "flexible" && <FlexibleForm prefill={prefill} />}
</Card>
</div>
</main>
</div>
</ErrorBoundary>
)
}
Loading
Loading