Skip to content
Closed
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
12 changes: 12 additions & 0 deletions .env.docker.example
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ backups/
*.log
coverage/
test-results/
.env.docker
51 changes: 51 additions & 0 deletions DOCKER_DEV.md
Original file line number Diff line number Diff line change
@@ -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
```
46 changes: 46 additions & 0 deletions apps/frontend/lib/api.abort.spec.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
65 changes: 46 additions & 19 deletions apps/frontend/lib/api.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,21 +14,47 @@ type JwtPayload = {
sub?: unknown;
};

export async function getAccessToken(publicKey: string): Promise<string> {
export function useAbortController(enabled = true) {
const controllerRef = useRef<AbortController | null>(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<string> {
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.");
}
Expand All @@ -51,6 +77,7 @@ export async function getAccessToken(publicKey: string): Promise<string> {
signature: signed.signedMessage,
nonce,
}),
signal,
});

if (!verifyResponse.ok) {
Expand All @@ -67,10 +94,7 @@ export async function getAccessToken(publicKey: string): Promise<string> {
}

export function clearAuthToken(): void {
if (typeof window === "undefined") {
return;
}

if (typeof window === "undefined") return;
window.localStorage.removeItem(TOKEN_STORAGE_KEY);
}

Expand All @@ -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;
Expand All @@ -96,23 +121,25 @@ 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<string> => {
setIsAuthenticating(true);
try {
return await getAccessToken(publicKey);
return await getAccessToken(publicKey, signal);
} finally {
setIsAuthenticating(false);
}
}, []);
}, [signal]);

return useMemo(
() => ({
getToken,
clearToken: clearAuthToken,
isAuthenticating,
apiUrl: API_URL,
abortAuth: abort,
}),
[getToken, isAuthenticating],
[getToken, abort]
);
}
103 changes: 103 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions scripts/init-db.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- StellarBounty — minimal init for local dev
-- Run automatically on first postgres start
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
Loading