diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..61e8972 --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,12 @@ +# Copy to .env.docker before running docker compose -f docker-compose.dev.yml up +DATABASE_URL=postgresql://postgres:password@postgres:5432/stellar_bounty + +# Backend +JWT_SECRET=local-dev-secret-change-me +STELLAR_NETWORK=testnet +STELLAR_RPC_URL=https://soroban-testnet.stellar.org +PORT=4000 + +# Frontend (must be public prefixed) +NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1 +NEXT_PUBLIC_STELLAR_NETWORK=TESTNET diff --git a/.gitignore b/.gitignore index a63a2cb..0c9dc86 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ backups/ *.log coverage/ test-results/ +.env.docker diff --git a/DOCKER_DEV.md b/DOCKER_DEV.md new file mode 100644 index 0000000..0d34af6 --- /dev/null +++ b/DOCKER_DEV.md @@ -0,0 +1,51 @@ +# Docker Compose for Local Development + +## Quick Start + +```bash +# 1. Copy env file +cp .env.docker .env + +# 2. Start everything (backend + frontend + postgres) +docker compose -f docker-compose.dev.yml up + +# 3. In another terminal — run migrations (first time only) +docker compose -f docker-compose.dev.yml exec backend-dev npm run migration:run --workspace=apps/backend + +# 4. Open +# Frontend: http://localhost:3000 +# Backend: http://localhost:4000 +# Postgres: localhost:5432 (postgres / password) +``` + +## Services + +| Service | Port | Purpose | +|-----------------|-------|----------------------------------| +| `postgres` | 5432 | PostgreSQL 16 with init SQL | +| `backend-dev` | 4000 | NestJS API (hot-reload) | +| `frontend-dev` | 3000 | Next.js frontend (fast refresh) | +| `contracts` | — | Rust/Soroban toolchain (optional)| + +## Hot-Reload + +Source code is bind-mounted into the containers. Edits in your editor reflect immediately: +- Backend: `nest start --watch` +- Frontend: `next dev` + +Node modules are stored inside the container (`/app/node_modules`) to avoid +platform-specific `node-sass` / native module issues. + +## Stopping + +```bash +docker compose -f docker-compose.dev.yml down +# Wipe data too: +docker compose -f docker-compose.dev.yml down -v +``` + +## Building Contracts (Optional) + +```bash +docker compose -f docker-compose.dev.yml --profile contracts up contracts +``` diff --git a/apps/frontend/lib/api.abort.spec.tsx b/apps/frontend/lib/api.abort.spec.tsx new file mode 100644 index 0000000..0eddc6a --- /dev/null +++ b/apps/frontend/lib/api.abort.spec.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useAbortController } from "./api"; + +describe("useAbortController", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("returns a signal that aborts on unmount", async () => { + const onAbort = vi.fn(); + const { result, unmount } = renderHook(() => + useAbortController(true) + ); + + const signal = result.current.signal; + expect(signal).toBeDefined(); + expect(signal.aborted).toBe(false); + + // Simulate component unmount + act(() => { + unmount(); + }); + + expect(result.current.signal.aborted).toBe(true); + }); + + it("does not create a controller when disabled", () => { + const { result } = renderHook(() => useAbortController(false)); + const signal = result.current.signal; + expect(signal).toBeDefined(); + expect(signal.aborted).toBe(false); + }); + + it("calling abort() cancels the signal", () => { + const { result } = renderHook(() => useAbortController(true)); + const signalBefore = result.current.signal; + expect(signalBefore.aborted).toBe(false); + + act(() => { + result.current.abort(); + }); + + expect(result.current.signal.aborted).toBe(true); + }); +}); diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index c440073..783a49a 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -1,10 +1,10 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useState, useEffect, useRef } from "react"; import { signMessage } from "@stellar/freighter-api"; const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000"; -const TOKEN_STORAGE_KEY = "stellar-bounty.auth-token"; +const TOKEN_STORAGE_KEY = "stellar_bounty_access_token"; type AuthTokenResponse = { accessToken: string; @@ -14,21 +14,47 @@ type JwtPayload = { sub?: unknown; }; -export async function getAccessToken(publicKey: string): Promise { +export function useAbortController(enabled = true) { + const controllerRef = useRef(null); + + useEffect(() => { + if (!enabled) return; + controllerRef.current = new AbortController(); + return () => { + if (controllerRef.current) { + controllerRef.current.abort(); + controllerRef.current = null; + } + }; + }, [enabled]); + + return { + get signal() { + return controllerRef.current?.signal ?? new AbortController().signal; + }, + abort: () => controllerRef.current?.abort(), + }; +} + +export async function getAccessToken( + publicKey: string, + signal?: AbortSignal, +): Promise { const savedToken = - typeof window !== "undefined" ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null; + typeof window !== "undefined" + ? window.localStorage.getItem(TOKEN_STORAGE_KEY) + : null; if (savedToken) { if (isTokenForPublicKey(savedToken, publicKey)) { return savedToken; } - clearAuthToken(); } - const challengeResponse = await fetch( - `${API_URL}/api/v1/auth/challenge?address=${encodeURIComponent(publicKey)}`, - ); + const challengeResponse = await fetch(`${API_URL}/api/v1/auth/challenge?address=${encodeURIComponent(publicKey)}`, { + signal, + }); if (!challengeResponse.ok) { throw new Error("Failed to request wallet challenge."); } @@ -51,6 +77,7 @@ export async function getAccessToken(publicKey: string): Promise { signature: signed.signedMessage, nonce, }), + signal, }); if (!verifyResponse.ok) { @@ -67,10 +94,7 @@ export async function getAccessToken(publicKey: string): Promise { } export function clearAuthToken(): void { - if (typeof window === "undefined") { - return; - } - + if (typeof window === "undefined") return; window.localStorage.removeItem(TOKEN_STORAGE_KEY); } @@ -81,13 +105,14 @@ function isTokenForPublicKey(token: string, publicKey: string): boolean { function decodeJwtPayload(token: string): JwtPayload | null { const [, encodedPayload] = token.split("."); - if (!encodedPayload || typeof globalThis.atob !== "function") { - return null; - } + if (!encodedPayload || typeof globalThis.atob !== "function") return null; try { const normalized = encodedPayload.replace(/-/g, "+").replace(/_/g, "/"); - const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "="); + const padded = normalized.padEnd( + normalized.length + ((4 - (normalized.length % 4)) % 4), + "=" + ); return JSON.parse(globalThis.atob(padded)) as JwtPayload; } catch { return null; @@ -96,15 +121,16 @@ function decodeJwtPayload(token: string): JwtPayload | null { export function useAuth() { const [isAuthenticating, setIsAuthenticating] = useState(false); + const { signal, abort } = useAbortController(); const getToken = useCallback(async (publicKey: string): Promise => { setIsAuthenticating(true); try { - return await getAccessToken(publicKey); + return await getAccessToken(publicKey, signal); } finally { setIsAuthenticating(false); } - }, []); + }, [signal]); return useMemo( () => ({ @@ -112,7 +138,8 @@ export function useAuth() { clearToken: clearAuthToken, isAuthenticating, apiUrl: API_URL, + abortAuth: abort, }), - [getToken, isAuthenticating], + [getToken, abort] ); } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..9db63c2 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,103 @@ +# ============================================================================= +# StellarBounty — Full-stack Docker Compose for local development +# ============================================================================= +# +# One command to start every service: +# docker compose -f docker-compose.dev.yml up +# +# Services: +# postgres — PostgreSQL 16 (with init SQL) +# backend-dev — NestJS API with hot-reload +# frontend-dev — Next.js frontend with hot-reload +# contracts — Rust toolchain (optional, on-demand) +# +# Ports: +# Frontend → http://localhost:3000 +# Backend → http://localhost:4000 +# Postgres → localhost:5432 +# +# Hot-reload: +# Source code is bind-mounted so edits in your editor are reflected +# immediately (NestJS watch mode + Next.js fast refresh). +# ============================================================================= + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: stellar_bounty + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 3s + retries: 5 + networks: [stellar] + + backend-dev: + image: node:20-alpine + working_dir: /app + env_file: .env.docker + environment: + DATABASE_URL: postgresql://postgres:password@postgres:5432/stellar_bounty + CORS_ORIGIN: http://localhost:3000 + volumes: + - ./apps/backend:/app/apps/backend + - ./package.json:/app/package.json + - ./package-lock.json:/app/package-lock.json + - /app/node_modules # keep container node_modules separate from host + ports: + - "4000:4000" + command: > + sh -c " + npm install --workspace=apps/backend --include-workspace-root && + npm run dev --workspace=apps/backend + " + depends_on: + postgres: + condition: service_healthy + networks: [stellar] + + frontend-dev: + image: node:20-alpine + working_dir: /app + env_file: .env.docker + environment: + NEXT_PUBLIC_API_URL: http://localhost:4000/api/v1 + NEXT_PUBLIC_STELLAR_NETWORK: TESTNET + volumes: + - ./apps/frontend:/app/apps/frontend + - ./package.json:/app/package.json + - ./package-lock.json:/app/package-lock.json + - /app/node_modules + ports: + - "3000:3000" + command: > + sh -c " + npm install --workspace=apps/frontend --include-workspace-root && + npm run dev --workspace=apps/frontend + " + depends_on: + - backend-dev + networks: [stellar] + + contracts: + image: rust:1.75-slim-bookworm + working_dir: /work + volumes: + - ./apps/contracts:/work + profiles: ["contracts"] # excluded from `up` by default + networks: [stellar] + +volumes: + postgres_data: + +networks: + stellar: + driver: bridge diff --git a/scripts/init-db.sql b/scripts/init-db.sql new file mode 100644 index 0000000..ae76a69 --- /dev/null +++ b/scripts/init-db.sql @@ -0,0 +1,3 @@ +-- StellarBounty — minimal init for local dev +-- Run automatically on first postgres start +CREATE EXTENSION IF NOT EXISTS "uuid-ossp";