From bb3cddf94d1e267e53355dfc2b295b02f7567c67 Mon Sep 17 00:00:00 2001 From: zhero-o Date: Sun, 29 Mar 2026 12:28:30 +0100 Subject: [PATCH] feat: add admin moderation panel API (#55) - Add DB migration (004) to support admin role, user ban fields, contract freeze fields, and admin_audit_log table - Implement withAdmin HOF and checkAdmin helper for role-based access control - Add audit logging utility that writes every moderation action to DB - Disputes: GET list (paginated, filterable), GET detail, PATCH status/resolution - Contracts: POST freeze, DELETE unfreeze with reason validation - Users: POST ban, DELETE unban with self-ban guard - Audit log: GET entries filterable by resource_type and admin_wallet - Add Vitest with 28 passing unit tests covering all endpoints and middleware --- __tests__/admin/contracts.test.ts | 120 +++ __tests__/admin/disputes.test.ts | 130 +++ __tests__/admin/helpers.ts | 19 + __tests__/admin/middleware.test.ts | 113 +++ __tests__/admin/users-ban.test.ts | 133 +++ app/api/admin/audit-log/route.ts | 52 + app/api/admin/contracts/[id]/freeze/route.ts | 121 +++ app/api/admin/disputes/[id]/route.ts | 123 +++ app/api/admin/disputes/route.ts | 71 ++ app/api/admin/users/[id]/ban/route.ts | 128 +++ lib/admin/audit.ts | 32 + lib/auth/admin-middleware.ts | 83 ++ package-lock.json | 956 ++++++++++++++++++- package.json | 8 +- scripts/004-admin-moderation.sql | 36 + vitest.config.ts | 14 + 16 files changed, 2131 insertions(+), 8 deletions(-) create mode 100644 __tests__/admin/contracts.test.ts create mode 100644 __tests__/admin/disputes.test.ts create mode 100644 __tests__/admin/helpers.ts create mode 100644 __tests__/admin/middleware.test.ts create mode 100644 __tests__/admin/users-ban.test.ts create mode 100644 app/api/admin/audit-log/route.ts create mode 100644 app/api/admin/contracts/[id]/freeze/route.ts create mode 100644 app/api/admin/disputes/[id]/route.ts create mode 100644 app/api/admin/disputes/route.ts create mode 100644 app/api/admin/users/[id]/ban/route.ts create mode 100644 lib/admin/audit.ts create mode 100644 lib/auth/admin-middleware.ts create mode 100644 scripts/004-admin-moderation.sql create mode 100644 vitest.config.ts diff --git a/__tests__/admin/contracts.test.ts b/__tests__/admin/contracts.test.ts new file mode 100644 index 0000000..a70a6f4 --- /dev/null +++ b/__tests__/admin/contracts.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextRequest } from 'next/server' + +vi.mock('@/lib/db', () => ({ sql: vi.fn() })) +vi.mock('@/lib/auth/session', () => ({ + readAccessToken: vi.fn(), + verifyAccessToken: vi.fn(), +})) +vi.mock('@/lib/admin/audit', () => ({ writeAuditLog: vi.fn() })) + +import { sql } from '@/lib/db' +import { readAccessToken, verifyAccessToken } from '@/lib/auth/session' +import { + POST as freezeContract, + DELETE as unfreezeContract, +} from '@/app/api/admin/contracts/[id]/freeze/route' + +const mockSql = vi.mocked(sql) +const mockReadToken = vi.mocked(readAccessToken) +const mockVerifyToken = vi.mocked(verifyAccessToken) + +function asAdmin() { + mockReadToken.mockReturnValue('token') + mockVerifyToken.mockReturnValue({ walletAddress: 'GADMIN', jti: 'jti' }) + mockSql.mockResolvedValueOnce([{ id: 1, user_type: 'admin', is_banned: false }] as never) +} + +function makeCtx(id: string) { + return { params: Promise.resolve({ id }) } +} + +beforeEach(() => vi.clearAllMocks()) + +describe('POST /api/admin/contracts/[id]/freeze', () => { + it('freezes a contract', async () => { + asAdmin() + mockSql + .mockResolvedValueOnce([{ id: 10, is_frozen: false, status: 'in_progress' }] as never) + .mockResolvedValueOnce([{ id: 10, is_frozen: true, frozen_at: new Date(), freeze_reason: 'Suspicious' }] as never) + + const req = new NextRequest('http://localhost/api/admin/contracts/10/freeze', { + method: 'POST', + body: JSON.stringify({ reason: 'Suspicious activity' }), + headers: { 'content-type': 'application/json' }, + }) + const res = await freezeContract(req, makeCtx('10')) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.contract.is_frozen).toBe(true) + }) + + it('returns 409 if already frozen', async () => { + asAdmin() + mockSql.mockResolvedValueOnce([{ id: 10, is_frozen: true, status: 'in_progress' }] as never) + + const req = new NextRequest('http://localhost/api/admin/contracts/10/freeze', { + method: 'POST', + body: JSON.stringify({ reason: 'Double freeze' }), + headers: { 'content-type': 'application/json' }, + }) + const res = await freezeContract(req, makeCtx('10')) + expect(res.status).toBe(409) + const body = await res.json() + expect(body.code).toBe('ALREADY_FROZEN') + }) + + it('returns 404 for unknown contract', async () => { + asAdmin() + mockSql.mockResolvedValueOnce([] as never) + + const req = new NextRequest('http://localhost/api/admin/contracts/999/freeze', { + method: 'POST', + body: JSON.stringify({ reason: 'Test' }), + headers: { 'content-type': 'application/json' }, + }) + const res = await freezeContract(req, makeCtx('999')) + expect(res.status).toBe(404) + }) + + it('returns 422 when reason is missing', async () => { + asAdmin() + const req = new NextRequest('http://localhost/api/admin/contracts/10/freeze', { + method: 'POST', + body: JSON.stringify({}), + headers: { 'content-type': 'application/json' }, + }) + const res = await freezeContract(req, makeCtx('10')) + expect(res.status).toBe(422) + }) +}) + +describe('DELETE /api/admin/contracts/[id]/freeze', () => { + it('unfreezes a contract', async () => { + asAdmin() + mockSql + .mockResolvedValueOnce([{ id: 10, is_frozen: true }] as never) + .mockResolvedValueOnce([{ id: 10, is_frozen: false, updated_at: new Date() }] as never) + + const req = new NextRequest('http://localhost/api/admin/contracts/10/freeze', { + method: 'DELETE', + }) + const res = await unfreezeContract(req, makeCtx('10')) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.contract.is_frozen).toBe(false) + }) + + it('returns 409 if contract is not frozen', async () => { + asAdmin() + mockSql.mockResolvedValueOnce([{ id: 10, is_frozen: false }] as never) + + const req = new NextRequest('http://localhost/api/admin/contracts/10/freeze', { + method: 'DELETE', + }) + const res = await unfreezeContract(req, makeCtx('10')) + expect(res.status).toBe(409) + const body = await res.json() + expect(body.code).toBe('NOT_FROZEN') + }) +}) diff --git a/__tests__/admin/disputes.test.ts b/__tests__/admin/disputes.test.ts new file mode 100644 index 0000000..04ee15d --- /dev/null +++ b/__tests__/admin/disputes.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextRequest } from 'next/server' + +vi.mock('@/lib/db', () => ({ sql: vi.fn() })) +vi.mock('@/lib/auth/session', () => ({ + readAccessToken: vi.fn(), + verifyAccessToken: vi.fn(), +})) +vi.mock('@/lib/admin/audit', () => ({ writeAuditLog: vi.fn() })) + +import { sql } from '@/lib/db' +import { readAccessToken, verifyAccessToken } from '@/lib/auth/session' +import { GET as listDisputes } from '@/app/api/admin/disputes/route' +import { GET as getDispute, PATCH as patchDispute } from '@/app/api/admin/disputes/[id]/route' + +const mockSql = vi.mocked(sql) +const mockReadToken = vi.mocked(readAccessToken) +const mockVerifyToken = vi.mocked(verifyAccessToken) + +function asAdmin() { + mockReadToken.mockReturnValue('token') + mockVerifyToken.mockReturnValue({ walletAddress: 'GADMIN', jti: 'jti' }) + // First sql call in checkAdmin/withAdmin resolves the admin user + mockSql.mockResolvedValueOnce([{ id: 1, user_type: 'admin', is_banned: false }] as never) +} + +function makeCtx(id: string) { + return { params: Promise.resolve({ id }) } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('GET /api/admin/disputes', () => { + it('returns list of disputes', async () => { + asAdmin() + const disputes = [{ id: 1, status: 'open', reason: 'test' }] + mockSql + .mockResolvedValueOnce(disputes as never) + .mockResolvedValueOnce([{ total: 1 }] as never) + + const req = new NextRequest('http://localhost/api/admin/disputes') + const res = await listDisputes(req) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.disputes).toHaveLength(1) + expect(body.pagination.total).toBe(1) + }) + + it('rejects invalid status filter', async () => { + asAdmin() + const req = new NextRequest('http://localhost/api/admin/disputes?status=bogus') + const res = await listDisputes(req) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.code).toBe('INVALID_STATUS') + }) +}) + +describe('GET /api/admin/disputes/[id]', () => { + it('returns 404 for unknown dispute', async () => { + asAdmin() + mockSql.mockResolvedValueOnce([] as never) + const req = new NextRequest('http://localhost/api/admin/disputes/999') + const res = await getDispute(req, makeCtx('999')) + expect(res.status).toBe(404) + }) + + it('returns dispute detail', async () => { + asAdmin() + const dispute = { id: 5, status: 'open', job_title: 'Fix bug' } + mockSql.mockResolvedValueOnce([dispute] as never) + const req = new NextRequest('http://localhost/api/admin/disputes/5') + const res = await getDispute(req, makeCtx('5')) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.dispute.id).toBe(5) + }) + + it('returns 400 for non-numeric id', async () => { + asAdmin() + const req = new NextRequest('http://localhost/api/admin/disputes/abc') + const res = await getDispute(req, makeCtx('abc')) + expect(res.status).toBe(400) + }) +}) + +describe('PATCH /api/admin/disputes/[id]', () => { + it('updates dispute status', async () => { + asAdmin() + mockSql + .mockResolvedValueOnce([{ id: 3 }] as never) // existing check + .mockResolvedValueOnce([{ id: 3, status: 'resolved', resolution: 'Closed', resolved_at: new Date(), updated_at: new Date() }] as never) // update + + const req = new NextRequest('http://localhost/api/admin/disputes/3', { + method: 'PATCH', + body: JSON.stringify({ status: 'resolved', resolution: 'Closed by admin' }), + headers: { 'content-type': 'application/json' }, + }) + const res = await patchDispute(req, makeCtx('3')) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.dispute.status).toBe('resolved') + }) + + it('returns 422 on invalid body', async () => { + asAdmin() + const req = new NextRequest('http://localhost/api/admin/disputes/3', { + method: 'PATCH', + body: JSON.stringify({ status: 'invalid_status' }), + headers: { 'content-type': 'application/json' }, + }) + const res = await patchDispute(req, makeCtx('3')) + expect(res.status).toBe(422) + }) + + it('returns 400 when body is empty of updates', async () => { + asAdmin() + const req = new NextRequest('http://localhost/api/admin/disputes/3', { + method: 'PATCH', + body: JSON.stringify({}), + headers: { 'content-type': 'application/json' }, + }) + const res = await patchDispute(req, makeCtx('3')) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.code).toBe('EMPTY_UPDATE') + }) +}) diff --git a/__tests__/admin/helpers.ts b/__tests__/admin/helpers.ts new file mode 100644 index 0000000..c834229 --- /dev/null +++ b/__tests__/admin/helpers.ts @@ -0,0 +1,19 @@ +import { NextRequest } from 'next/server' + +export function makeRequest( + method: string, + url: string, + body?: unknown, + headers: Record = {} +): NextRequest { + const init: RequestInit = { method, headers } + if (body !== undefined) { + init.body = JSON.stringify(body) + ;(init.headers as Record)['content-type'] = 'application/json' + } + return new NextRequest(url, init) +} + +export function adminToken() { + return 'Bearer valid-admin-token' +} diff --git a/__tests__/admin/middleware.test.ts b/__tests__/admin/middleware.test.ts new file mode 100644 index 0000000..08e5722 --- /dev/null +++ b/__tests__/admin/middleware.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextRequest, NextResponse } from 'next/server' + +vi.mock('@/lib/db', () => ({ + sql: vi.fn(), +})) + +vi.mock('@/lib/auth/session', () => ({ + readAccessToken: vi.fn(), + verifyAccessToken: vi.fn(), +})) + +import { sql } from '@/lib/db' +import { readAccessToken, verifyAccessToken } from '@/lib/auth/session' +import { checkAdmin, withAdmin } from '@/lib/auth/admin-middleware' + +const mockSql = vi.mocked(sql) +const mockReadToken = vi.mocked(readAccessToken) +const mockVerifyToken = vi.mocked(verifyAccessToken) + +function makeReq(url = 'http://localhost/api/admin/test') { + return new NextRequest(url) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('checkAdmin', () => { + it('returns 401 when no token present', async () => { + mockReadToken.mockReturnValue(null) + const result = await checkAdmin(makeReq()) + expect(result).toBeInstanceOf(NextResponse) + const res = result as NextResponse + expect(res.status).toBe(401) + const body = await res.json() + expect(body.code).toBe('AUTH_REQUIRED') + }) + + it('returns 401 when token is invalid', async () => { + mockReadToken.mockReturnValue('bad-token') + mockVerifyToken.mockReturnValue(null) + const result = await checkAdmin(makeReq()) + expect(result).toBeInstanceOf(NextResponse) + const res = result as NextResponse + expect(res.status).toBe(401) + }) + + it('returns 403 when user is not admin', async () => { + mockReadToken.mockReturnValue('valid-token') + mockVerifyToken.mockReturnValue({ walletAddress: 'GTEST', jti: 'jti1' }) + mockSql.mockResolvedValue([{ id: 1, user_type: 'client', is_banned: false }] as never) + const result = await checkAdmin(makeReq()) + expect(result).toBeInstanceOf(NextResponse) + const res = result as NextResponse + expect(res.status).toBe(403) + const body = await res.json() + expect(body.code).toBe('ADMIN_REQUIRED') + }) + + it('returns 403 when admin user is banned', async () => { + mockReadToken.mockReturnValue('valid-token') + mockVerifyToken.mockReturnValue({ walletAddress: 'GADMIN', jti: 'jti2' }) + mockSql.mockResolvedValue([{ id: 2, user_type: 'admin', is_banned: true }] as never) + const result = await checkAdmin(makeReq()) + expect(result).toBeInstanceOf(NextResponse) + const res = result as NextResponse + expect(res.status).toBe(403) + const body = await res.json() + expect(body.code).toBe('ACCOUNT_BANNED') + }) + + it('returns AdminContext for valid admin', async () => { + mockReadToken.mockReturnValue('valid-token') + mockVerifyToken.mockReturnValue({ walletAddress: 'GADMIN', jti: 'jti3' }) + mockSql.mockResolvedValue([{ id: 5, user_type: 'admin', is_banned: false }] as never) + const result = await checkAdmin(makeReq()) + expect(result).not.toBeInstanceOf(NextResponse) + const ctx = result as Awaited> + if (ctx instanceof NextResponse) throw new Error('unexpected') + expect(ctx.userId).toBe(5) + expect(ctx.walletAddress).toBe('GADMIN') + }) +}) + +describe('withAdmin HOF', () => { + it('calls handler with admin context on valid admin', async () => { + mockReadToken.mockReturnValue('valid-token') + mockVerifyToken.mockReturnValue({ walletAddress: 'GADMIN', jti: 'jti4' }) + mockSql.mockResolvedValue([{ id: 7, user_type: 'admin', is_banned: false }] as never) + + const handler = vi.fn().mockResolvedValue(NextResponse.json({ ok: true })) + const route = withAdmin(handler) + const req = makeReq() + const res = await route(req) + + expect(handler).toHaveBeenCalledOnce() + expect(res.status).toBe(200) + }) + + it('does not call handler when not admin', async () => { + mockReadToken.mockReturnValue('valid-token') + mockVerifyToken.mockReturnValue({ walletAddress: 'GUSER', jti: 'jti5' }) + mockSql.mockResolvedValue([{ id: 3, user_type: 'freelancer', is_banned: false }] as never) + + const handler = vi.fn() + const route = withAdmin(handler) + const res = await route(makeReq()) + + expect(handler).not.toHaveBeenCalled() + expect(res.status).toBe(403) + }) +}) diff --git a/__tests__/admin/users-ban.test.ts b/__tests__/admin/users-ban.test.ts new file mode 100644 index 0000000..6a21e03 --- /dev/null +++ b/__tests__/admin/users-ban.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextRequest } from 'next/server' + +vi.mock('@/lib/db', () => ({ sql: vi.fn() })) +vi.mock('@/lib/auth/session', () => ({ + readAccessToken: vi.fn(), + verifyAccessToken: vi.fn(), +})) +vi.mock('@/lib/admin/audit', () => ({ writeAuditLog: vi.fn() })) + +import { sql } from '@/lib/db' +import { readAccessToken, verifyAccessToken } from '@/lib/auth/session' +import { + POST as banUser, + DELETE as unbanUser, +} from '@/app/api/admin/users/[id]/ban/route' + +const mockSql = vi.mocked(sql) +const mockReadToken = vi.mocked(readAccessToken) +const mockVerifyToken = vi.mocked(verifyAccessToken) + +function asAdmin(adminId = 1) { + mockReadToken.mockReturnValue('token') + mockVerifyToken.mockReturnValue({ walletAddress: 'GADMIN', jti: 'jti' }) + mockSql.mockResolvedValueOnce([{ id: adminId, user_type: 'admin', is_banned: false }] as never) +} + +function makeCtx(id: string) { + return { params: Promise.resolve({ id }) } +} + +beforeEach(() => vi.clearAllMocks()) + +describe('POST /api/admin/users/[id]/ban', () => { + it('bans a user', async () => { + asAdmin() + mockSql + .mockResolvedValueOnce([{ id: 42, is_banned: false, username: 'bob' }] as never) + .mockResolvedValueOnce([{ id: 42, username: 'bob', is_banned: true, banned_at: new Date(), ban_reason: 'Fraud' }] as never) + + const req = new NextRequest('http://localhost/api/admin/users/42/ban', { + method: 'POST', + body: JSON.stringify({ reason: 'Fraudulent activity' }), + headers: { 'content-type': 'application/json' }, + }) + const res = await banUser(req, makeCtx('42')) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.user.is_banned).toBe(true) + }) + + it('returns 400 when admin tries to ban themselves', async () => { + asAdmin(5) + const req = new NextRequest('http://localhost/api/admin/users/5/ban', { + method: 'POST', + body: JSON.stringify({ reason: 'Self' }), + headers: { 'content-type': 'application/json' }, + }) + const res = await banUser(req, makeCtx('5')) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.code).toBe('SELF_BAN') + }) + + it('returns 409 if user is already banned', async () => { + asAdmin() + mockSql.mockResolvedValueOnce([{ id: 42, is_banned: true, username: 'bob' }] as never) + + const req = new NextRequest('http://localhost/api/admin/users/42/ban', { + method: 'POST', + body: JSON.stringify({ reason: 'Fraud' }), + headers: { 'content-type': 'application/json' }, + }) + const res = await banUser(req, makeCtx('42')) + expect(res.status).toBe(409) + const body = await res.json() + expect(body.code).toBe('ALREADY_BANNED') + }) + + it('returns 404 for unknown user', async () => { + asAdmin() + mockSql.mockResolvedValueOnce([] as never) + + const req = new NextRequest('http://localhost/api/admin/users/999/ban', { + method: 'POST', + body: JSON.stringify({ reason: 'Test' }), + headers: { 'content-type': 'application/json' }, + }) + const res = await banUser(req, makeCtx('999')) + expect(res.status).toBe(404) + }) + + it('returns 422 when reason is missing', async () => { + asAdmin() + const req = new NextRequest('http://localhost/api/admin/users/42/ban', { + method: 'POST', + body: JSON.stringify({}), + headers: { 'content-type': 'application/json' }, + }) + const res = await banUser(req, makeCtx('42')) + expect(res.status).toBe(422) + }) +}) + +describe('DELETE /api/admin/users/[id]/ban', () => { + it('unbans a user', async () => { + asAdmin() + mockSql + .mockResolvedValueOnce([{ id: 42, is_banned: true }] as never) + .mockResolvedValueOnce([{ id: 42, username: 'bob', is_banned: false, updated_at: new Date() }] as never) + + const req = new NextRequest('http://localhost/api/admin/users/42/ban', { + method: 'DELETE', + }) + const res = await unbanUser(req, makeCtx('42')) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.user.is_banned).toBe(false) + }) + + it('returns 409 if user is not banned', async () => { + asAdmin() + mockSql.mockResolvedValueOnce([{ id: 42, is_banned: false }] as never) + + const req = new NextRequest('http://localhost/api/admin/users/42/ban', { + method: 'DELETE', + }) + const res = await unbanUser(req, makeCtx('42')) + expect(res.status).toBe(409) + const body = await res.json() + expect(body.code).toBe('NOT_BANNED') + }) +}) diff --git a/app/api/admin/audit-log/route.ts b/app/api/admin/audit-log/route.ts new file mode 100644 index 0000000..528b67c --- /dev/null +++ b/app/api/admin/audit-log/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server' +import { withAdmin } from '@/lib/auth/admin-middleware' +import { sql } from '@/lib/db' + +const PAGE_SIZE = 50 + +const VALID_RESOURCE_TYPES = ['dispute', 'contract', 'user'] as const + +export const GET = withAdmin(async (request: NextRequest) => { + const params = request.nextUrl.searchParams + const page = Math.max(1, parseInt(params.get('page') ?? '1', 10)) + const resourceType = params.get('resource_type') + const adminWallet = params.get('admin_wallet') + const offset = (page - 1) * PAGE_SIZE + + if ( + resourceType && + !VALID_RESOURCE_TYPES.includes(resourceType as (typeof VALID_RESOURCE_TYPES)[number]) + ) { + return NextResponse.json( + { error: 'Invalid resource_type filter', code: 'INVALID_RESOURCE_TYPE' }, + { status: 400 } + ) + } + + const rows = await sql` + SELECT id, admin_wallet, action, resource_type, resource_id, details, created_at + FROM admin_audit_log + WHERE + (${resourceType ?? null} IS NULL OR resource_type = ${resourceType ?? null}) + AND (${adminWallet ?? null} IS NULL OR admin_wallet = ${adminWallet ?? null}) + ORDER BY created_at DESC + LIMIT ${PAGE_SIZE} OFFSET ${offset} + ` + + const [countRow] = await sql` + SELECT COUNT(*)::int AS total + FROM admin_audit_log + WHERE + (${resourceType ?? null} IS NULL OR resource_type = ${resourceType ?? null}) + AND (${adminWallet ?? null} IS NULL OR admin_wallet = ${adminWallet ?? null}) + ` + + return NextResponse.json({ + entries: rows, + pagination: { + page, + pageSize: PAGE_SIZE, + total: (countRow as { total: number }).total, + }, + }) +}) diff --git a/app/api/admin/contracts/[id]/freeze/route.ts b/app/api/admin/contracts/[id]/freeze/route.ts new file mode 100644 index 0000000..16c8300 --- /dev/null +++ b/app/api/admin/contracts/[id]/freeze/route.ts @@ -0,0 +1,121 @@ +import { NextRequest, NextResponse } from 'next/server' +import { checkAdmin } from '@/lib/auth/admin-middleware' +import { writeAuditLog } from '@/lib/admin/audit' +import { sql } from '@/lib/db' +import { z } from 'zod' + +type RouteContext = { params: Promise<{ id: string }> } + +const freezeSchema = z.object({ + reason: z.string().min(1).max(500), +}) + +export async function POST(request: NextRequest, context: RouteContext) { + const adminOrError = await checkAdmin(request) + if (adminOrError instanceof NextResponse) return adminOrError + const admin = adminOrError + + const { id } = await context.params + const contractId = parseInt(id, 10) + if (isNaN(contractId)) { + return NextResponse.json({ error: 'Invalid contract ID', code: 'INVALID_ID' }, { status: 400 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body', code: 'INVALID_BODY' }, { status: 400 }) + } + + const parsed = freezeSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', code: 'VALIDATION_ERROR', issues: parsed.error.issues }, + { status: 422 } + ) + } + + const existing = await sql` + SELECT id, is_frozen, status FROM jobs WHERE id = ${contractId} + ` + if (!existing[0]) { + return NextResponse.json({ error: 'Contract not found', code: 'NOT_FOUND' }, { status: 404 }) + } + + const job = existing[0] as { id: number; is_frozen: boolean; status: string } + if (job.is_frozen) { + return NextResponse.json( + { error: 'Contract is already frozen', code: 'ALREADY_FROZEN' }, + { status: 409 } + ) + } + + const updated = await sql` + UPDATE jobs + SET + is_frozen = TRUE, + frozen_at = NOW(), + freeze_reason = ${parsed.data.reason}, + updated_at = NOW() + WHERE id = ${contractId} + RETURNING id, status, is_frozen, frozen_at, freeze_reason + ` + + await writeAuditLog({ + adminWallet: admin.walletAddress, + action: 'contract.freeze', + resourceType: 'contract', + resourceId: contractId, + details: { reason: parsed.data.reason }, + }) + + return NextResponse.json({ contract: updated[0] }, { status: 200 }) +} + +export async function DELETE(request: NextRequest, context: RouteContext) { + const adminOrError = await checkAdmin(request) + if (adminOrError instanceof NextResponse) return adminOrError + const admin = adminOrError + + const { id } = await context.params + const contractId = parseInt(id, 10) + if (isNaN(contractId)) { + return NextResponse.json({ error: 'Invalid contract ID', code: 'INVALID_ID' }, { status: 400 }) + } + + const existing = await sql` + SELECT id, is_frozen FROM jobs WHERE id = ${contractId} + ` + if (!existing[0]) { + return NextResponse.json({ error: 'Contract not found', code: 'NOT_FOUND' }, { status: 404 }) + } + + const job = existing[0] as { id: number; is_frozen: boolean } + if (!job.is_frozen) { + return NextResponse.json( + { error: 'Contract is not frozen', code: 'NOT_FROZEN' }, + { status: 409 } + ) + } + + const updated = await sql` + UPDATE jobs + SET + is_frozen = FALSE, + frozen_at = NULL, + freeze_reason = NULL, + updated_at = NOW() + WHERE id = ${contractId} + RETURNING id, status, is_frozen, updated_at + ` + + await writeAuditLog({ + adminWallet: admin.walletAddress, + action: 'contract.unfreeze', + resourceType: 'contract', + resourceId: contractId, + }) + + return NextResponse.json({ contract: updated[0] }, { status: 200 }) +} diff --git a/app/api/admin/disputes/[id]/route.ts b/app/api/admin/disputes/[id]/route.ts new file mode 100644 index 0000000..7ae6f36 --- /dev/null +++ b/app/api/admin/disputes/[id]/route.ts @@ -0,0 +1,123 @@ +import { NextRequest, NextResponse } from 'next/server' +import { checkAdmin } from '@/lib/auth/admin-middleware' +import { writeAuditLog } from '@/lib/admin/audit' +import { sql } from '@/lib/db' +import { z } from 'zod' + +type RouteContext = { params: Promise<{ id: string }> } + +const updateSchema = z.object({ + status: z.enum(['open', 'under_review', 'resolved']).optional(), + resolution: z.string().min(1).max(2000).optional(), +}) + +export async function GET(request: NextRequest, context: RouteContext) { + const adminOrError = await checkAdmin(request) + if (adminOrError instanceof NextResponse) return adminOrError + const admin = adminOrError + + const { id } = await context.params + const disputeId = parseInt(id, 10) + if (isNaN(disputeId)) { + return NextResponse.json({ error: 'Invalid dispute ID', code: 'INVALID_ID' }, { status: 400 }) + } + + const rows = await sql` + SELECT + d.id, d.job_id, d.reason, d.status, d.resolution, + d.resolved_at, d.created_at, d.updated_at, + u.id AS raised_by_id, + u.username AS raised_by_username, + u.wallet_address AS raised_by_wallet, + j.title AS job_title, + j.status AS job_status, + j.budget, j.currency, + j.is_frozen, + c.username AS client_username, + f.username AS freelancer_username + FROM disputes d + JOIN users u ON u.id = d.raised_by + JOIN jobs j ON j.id = d.job_id + JOIN users c ON c.id = j.client_id + LEFT JOIN users f ON f.id = j.freelancer_id + WHERE d.id = ${disputeId} + ` + + if (!rows[0]) { + return NextResponse.json({ error: 'Dispute not found', code: 'NOT_FOUND' }, { status: 404 }) + } + + await writeAuditLog({ + adminWallet: admin.walletAddress, + action: 'dispute.view', + resourceType: 'dispute', + resourceId: disputeId, + }) + + return NextResponse.json({ dispute: rows[0] }) +} + +export async function PATCH(request: NextRequest, context: RouteContext) { + const adminOrError = await checkAdmin(request) + if (adminOrError instanceof NextResponse) return adminOrError + const admin = adminOrError + + const { id } = await context.params + const disputeId = parseInt(id, 10) + if (isNaN(disputeId)) { + return NextResponse.json({ error: 'Invalid dispute ID', code: 'INVALID_ID' }, { status: 400 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body', code: 'INVALID_BODY' }, { status: 400 }) + } + + const parsed = updateSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', code: 'VALIDATION_ERROR', issues: parsed.error.issues }, + { status: 422 } + ) + } + + const { status, resolution } = parsed.data + if (!status && !resolution) { + return NextResponse.json( + { error: 'Provide at least one field to update', code: 'EMPTY_UPDATE' }, + { status: 400 } + ) + } + + const existing = await sql`SELECT id FROM disputes WHERE id = ${disputeId}` + if (!existing[0]) { + return NextResponse.json({ error: 'Dispute not found', code: 'NOT_FOUND' }, { status: 404 }) + } + + const updated = await sql` + UPDATE disputes + SET + status = COALESCE(${status ?? null}, status), + resolution = COALESCE(${resolution ?? null}, resolution), + resolved_at = CASE + WHEN ${status ?? null} = 'resolved' AND resolved_at IS NULL + THEN NOW() + ELSE resolved_at + END, + updated_at = NOW() + WHERE id = ${disputeId} + RETURNING id, status, resolution, resolved_at, updated_at + ` + + await writeAuditLog({ + adminWallet: admin.walletAddress, + action: 'dispute.update', + resourceType: 'dispute', + resourceId: disputeId, + details: { status, resolution }, + }) + + return NextResponse.json({ dispute: updated[0] }) +} diff --git a/app/api/admin/disputes/route.ts b/app/api/admin/disputes/route.ts new file mode 100644 index 0000000..714a8ff --- /dev/null +++ b/app/api/admin/disputes/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server' +import { withAdmin } from '@/lib/auth/admin-middleware' +import { writeAuditLog } from '@/lib/admin/audit' +import { sql } from '@/lib/db' + +const VALID_STATUSES = ['open', 'under_review', 'resolved'] as const +const PAGE_SIZE = 20 + +export const GET = withAdmin(async (request: NextRequest, admin) => { + const params = request.nextUrl.searchParams + const status = params.get('status') + const page = Math.max(1, parseInt(params.get('page') ?? '1', 10)) + const offset = (page - 1) * PAGE_SIZE + + if (status && !VALID_STATUSES.includes(status as (typeof VALID_STATUSES)[number])) { + return NextResponse.json( + { error: 'Invalid status filter', code: 'INVALID_STATUS' }, + { status: 400 } + ) + } + + const rows = status + ? await sql` + SELECT + d.id, d.job_id, d.reason, d.status, d.resolution, + d.resolved_at, d.created_at, d.updated_at, + u.username AS raised_by_username, + u.wallet_address AS raised_by_wallet, + j.title AS job_title + FROM disputes d + JOIN users u ON u.id = d.raised_by + JOIN jobs j ON j.id = d.job_id + WHERE d.status = ${status} + ORDER BY d.created_at DESC + LIMIT ${PAGE_SIZE} OFFSET ${offset} + ` + : await sql` + SELECT + d.id, d.job_id, d.reason, d.status, d.resolution, + d.resolved_at, d.created_at, d.updated_at, + u.username AS raised_by_username, + u.wallet_address AS raised_by_wallet, + j.title AS job_title + FROM disputes d + JOIN users u ON u.id = d.raised_by + JOIN jobs j ON j.id = d.job_id + ORDER BY d.created_at DESC + LIMIT ${PAGE_SIZE} OFFSET ${offset} + ` + + const [countRow] = status + ? await sql`SELECT COUNT(*)::int AS total FROM disputes WHERE status = ${status}` + : await sql`SELECT COUNT(*)::int AS total FROM disputes` + + await writeAuditLog({ + adminWallet: admin.walletAddress, + action: 'dispute.view', + resourceType: 'dispute', + resourceId: 0, + details: { filter: status ?? 'all', page }, + }) + + return NextResponse.json({ + disputes: rows, + pagination: { + page, + pageSize: PAGE_SIZE, + total: (countRow as { total: number }).total, + }, + }) +}) diff --git a/app/api/admin/users/[id]/ban/route.ts b/app/api/admin/users/[id]/ban/route.ts new file mode 100644 index 0000000..aeee9ac --- /dev/null +++ b/app/api/admin/users/[id]/ban/route.ts @@ -0,0 +1,128 @@ +import { NextRequest, NextResponse } from 'next/server' +import { checkAdmin } from '@/lib/auth/admin-middleware' +import { writeAuditLog } from '@/lib/admin/audit' +import { sql } from '@/lib/db' +import { z } from 'zod' + +type RouteContext = { params: Promise<{ id: string }> } + +const banSchema = z.object({ + reason: z.string().min(1).max(500), +}) + +export async function POST(request: NextRequest, context: RouteContext) { + const adminOrError = await checkAdmin(request) + if (adminOrError instanceof NextResponse) return adminOrError + const admin = adminOrError + + const { id } = await context.params + const userId = parseInt(id, 10) + if (isNaN(userId)) { + return NextResponse.json({ error: 'Invalid user ID', code: 'INVALID_ID' }, { status: 400 }) + } + + if (userId === admin.userId) { + return NextResponse.json( + { error: 'Admins cannot ban themselves', code: 'SELF_BAN' }, + { status: 400 } + ) + } + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body', code: 'INVALID_BODY' }, { status: 400 }) + } + + const parsed = banSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', code: 'VALIDATION_ERROR', issues: parsed.error.issues }, + { status: 422 } + ) + } + + const existing = await sql` + SELECT id, is_banned, username FROM users WHERE id = ${userId} + ` + if (!existing[0]) { + return NextResponse.json({ error: 'User not found', code: 'NOT_FOUND' }, { status: 404 }) + } + + const user = existing[0] as { id: number; is_banned: boolean; username: string } + if (user.is_banned) { + return NextResponse.json( + { error: 'User is already banned', code: 'ALREADY_BANNED' }, + { status: 409 } + ) + } + + const updated = await sql` + UPDATE users + SET + is_banned = TRUE, + banned_at = NOW(), + ban_reason = ${parsed.data.reason}, + updated_at = NOW() + WHERE id = ${userId} + RETURNING id, username, is_banned, banned_at, ban_reason + ` + + await writeAuditLog({ + adminWallet: admin.walletAddress, + action: 'user.ban', + resourceType: 'user', + resourceId: userId, + details: { reason: parsed.data.reason }, + }) + + return NextResponse.json({ user: updated[0] }, { status: 200 }) +} + +export async function DELETE(request: NextRequest, context: RouteContext) { + const adminOrError = await checkAdmin(request) + if (adminOrError instanceof NextResponse) return adminOrError + const admin = adminOrError + + const { id } = await context.params + const userId = parseInt(id, 10) + if (isNaN(userId)) { + return NextResponse.json({ error: 'Invalid user ID', code: 'INVALID_ID' }, { status: 400 }) + } + + const existing = await sql` + SELECT id, is_banned FROM users WHERE id = ${userId} + ` + if (!existing[0]) { + return NextResponse.json({ error: 'User not found', code: 'NOT_FOUND' }, { status: 404 }) + } + + const user = existing[0] as { id: number; is_banned: boolean } + if (!user.is_banned) { + return NextResponse.json( + { error: 'User is not banned', code: 'NOT_BANNED' }, + { status: 409 } + ) + } + + const updated = await sql` + UPDATE users + SET + is_banned = FALSE, + banned_at = NULL, + ban_reason = NULL, + updated_at = NOW() + WHERE id = ${userId} + RETURNING id, username, is_banned, updated_at + ` + + await writeAuditLog({ + adminWallet: admin.walletAddress, + action: 'user.unban', + resourceType: 'user', + resourceId: userId, + }) + + return NextResponse.json({ user: updated[0] }, { status: 200 }) +} diff --git a/lib/admin/audit.ts b/lib/admin/audit.ts new file mode 100644 index 0000000..caf2850 --- /dev/null +++ b/lib/admin/audit.ts @@ -0,0 +1,32 @@ +import { sql } from '@/lib/db' + +export type AuditAction = + | 'dispute.view' + | 'dispute.update' + | 'contract.freeze' + | 'contract.unfreeze' + | 'user.ban' + | 'user.unban' + +export type AuditResourceType = 'dispute' | 'contract' | 'user' + +export interface AuditEntry { + adminWallet: string + action: AuditAction + resourceType: AuditResourceType + resourceId: number + details?: Record +} + +export async function writeAuditLog(entry: AuditEntry): Promise { + await sql` + INSERT INTO admin_audit_log (admin_wallet, action, resource_type, resource_id, details) + VALUES ( + ${entry.adminWallet}, + ${entry.action}, + ${entry.resourceType}, + ${entry.resourceId}, + ${entry.details ? JSON.stringify(entry.details) : null} + ) + ` +} diff --git a/lib/auth/admin-middleware.ts b/lib/auth/admin-middleware.ts new file mode 100644 index 0000000..ba35c03 --- /dev/null +++ b/lib/auth/admin-middleware.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from 'next/server' +import { sql } from '@/lib/db' +import { AuthContext, withAuth } from '@/lib/auth/middleware' + +export interface AdminContext extends AuthContext { + userId: number +} + +type AdminHandler = ( + request: NextRequest, + admin: AdminContext +) => Promise | NextResponse + +/** HOF for non-dynamic routes */ +export function withAdmin(handler: AdminHandler) { + return withAuth(async (request: NextRequest, auth: AuthContext) => { + const adminOrError = await resolveAdmin(auth) + if (adminOrError instanceof NextResponse) return adminOrError + return handler(request, adminOrError) + }) +} + +/** + * Helper for dynamic routes that receive a route context as their second arg. + * Returns AdminContext on success, or a NextResponse error to return immediately. + * + * Usage: + * const adminOrError = await checkAdmin(request) + * if (adminOrError instanceof NextResponse) return adminOrError + */ +export async function checkAdmin( + request: NextRequest +): Promise { + const { readAccessToken, verifyAccessToken } = await import('@/lib/auth/session') + const token = readAccessToken(request) + if (!token) { + return NextResponse.json( + { error: 'Unauthorized', code: 'AUTH_REQUIRED' }, + { status: 401 } + ) + } + const payload = verifyAccessToken(token) + if (!payload) { + return NextResponse.json( + { error: 'Unauthorized', code: 'AUTH_REQUIRED' }, + { status: 401 } + ) + } + return resolveAdmin({ walletAddress: payload.walletAddress, tokenJti: payload.jti }) +} + +async function resolveAdmin(auth: AuthContext): Promise { + const rows = await sql` + SELECT id, user_type, is_banned + FROM users + WHERE wallet_address = ${auth.walletAddress} + LIMIT 1 + ` + + const user = rows[0] as + | { id: number; user_type: string; is_banned: boolean } + | undefined + + if (!user || user.user_type !== 'admin') { + return NextResponse.json( + { error: 'Forbidden', code: 'ADMIN_REQUIRED' }, + { status: 403 } + ) + } + + if (user.is_banned) { + return NextResponse.json( + { error: 'Account suspended', code: 'ACCOUNT_BANNED' }, + { status: 403 } + ) + } + + return { + walletAddress: auth.walletAddress, + tokenJti: auth.tokenJti, + userId: user.id, + } +} diff --git a/package-lock.json b/package-lock.json index c1a4fc5..32d0f9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,6 +68,7 @@ "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/coverage-v8": "^4.1.2", "baseline-browser-mapping": "^2.10.0", "eslint": "^9.39.3", "eslint-config-next": "^16.1.6", @@ -75,7 +76,8 @@ "tailwindcss": "^4.1.9", "tsx": "^4.21.0", "tw-animate-css": "1.3.3", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.2" } }, "node_modules/@alloc/quick-lru": { @@ -340,6 +342,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@date-fns/tz": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", @@ -1825,6 +1837,16 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", @@ -4192,6 +4214,305 @@ "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", "license": "MIT" }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -4199,6 +4520,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@stellar/js-xdr": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", @@ -4545,6 +4873,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -4608,6 +4947,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5254,6 +5600,150 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", + "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.2", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.2", + "vitest": "4.1.2" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -5499,10 +5989,39 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ast-types-flow": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", - "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true, "license": "MIT" }, @@ -5819,6 +6338,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6464,6 +6993,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -7006,6 +7542,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -7031,6 +7577,16 @@ "node": ">=12.0.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7559,6 +8115,13 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -8094,6 +8657,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -8579,6 +9181,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -8941,6 +9584,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -9049,6 +9703,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", @@ -10952,6 +11613,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -11298,6 +11993,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sonner": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", @@ -11324,6 +12026,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -11568,6 +12284,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -11616,6 +12349,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -12045,6 +12788,192 @@ "d3-timer": "^3.0.1" } }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -12149,6 +13078,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 1e14f96..8e72ecc 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "next dev", "lint": "eslint .", "start": "next start", + "test": "vitest run", "worker": "tsx scripts/worker.ts" }, "dependencies": { @@ -45,7 +46,6 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.0.4", - "date-fns": "^4.1.0", "date-fns": "4.1.0", "dotenv": "^17.3.1", "embla-carousel-react": "8.5.1", @@ -71,6 +71,7 @@ "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/coverage-v8": "^4.1.2", "baseline-browser-mapping": "^2.10.0", "eslint": "^9.39.3", "eslint-config-next": "^16.1.6", @@ -78,6 +79,7 @@ "tailwindcss": "^4.1.9", "tsx": "^4.21.0", "tw-animate-css": "1.3.3", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.2" } -} \ No newline at end of file +} diff --git a/scripts/004-admin-moderation.sql b/scripts/004-admin-moderation.sql new file mode 100644 index 0000000..e173c64 --- /dev/null +++ b/scripts/004-admin-moderation.sql @@ -0,0 +1,36 @@ +-- Admin Moderation Panel Schema +-- Adds admin role, ban/freeze fields, and audit logging + +-- Extend user_type to include 'admin' +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_user_type_check; +ALTER TABLE users ADD CONSTRAINT users_user_type_check + CHECK (user_type IN ('client', 'freelancer', 'both', 'admin')); + +-- Ban fields on users +ALTER TABLE users + ADD COLUMN IF NOT EXISTS is_banned BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS banned_at TIMESTAMP, + ADD COLUMN IF NOT EXISTS ban_reason TEXT; + +-- Freeze fields on jobs (separate from status so existing status is preserved) +ALTER TABLE jobs + ADD COLUMN IF NOT EXISTS is_frozen BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS frozen_at TIMESTAMP, + ADD COLUMN IF NOT EXISTS freeze_reason TEXT; + +-- Admin audit log +CREATE TABLE IF NOT EXISTS admin_audit_log ( + id SERIAL PRIMARY KEY, + admin_wallet VARCHAR(56) NOT NULL, + action VARCHAR(50) NOT NULL, + resource_type VARCHAR(30) NOT NULL, + resource_id INTEGER NOT NULL, + details JSONB, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_audit_log_admin ON admin_audit_log(admin_wallet); +CREATE INDEX IF NOT EXISTS idx_audit_log_resource ON admin_audit_log(resource_type, resource_id); +CREATE INDEX IF NOT EXISTS idx_audit_log_created ON admin_audit_log(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_users_banned ON users(is_banned) WHERE is_banned = TRUE; +CREATE INDEX IF NOT EXISTS idx_jobs_frozen ON jobs(is_frozen) WHERE is_frozen = TRUE; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..e6fe532 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config' +import path from 'path' + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + }, + }, +})