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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,26 @@ services:
start_period: 15s
retries: 3

# ── Frontend ────────────────────────────────────────────────────────────────
# NEXT_PUBLIC_* values are baked in at build time. The browser calls the API
# on the host port, so the default points at http://localhost:3001.
web:
build:
context: ./frontend
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3001}
NEXT_PUBLIC_STELLAR_NETWORK: ${NEXT_PUBLIC_STELLAR_NETWORK:-testnet}
NEXT_PUBLIC_SOROBAN_RPC_URL: ${NEXT_PUBLIC_SOROBAN_RPC_URL:-https://soroban-testnet.stellar.org}
NEXT_PUBLIC_MARKET_CONTRACT_ID: ${NEXT_PUBLIC_MARKET_CONTRACT_ID:-}
NEXT_PUBLIC_ADMIN_ADDRESSES: ${NEXT_PUBLIC_ADMIN_ADDRESSES:-}
restart: unless-stopped
ports:
- "3000:3000"
depends_on:
api:
condition: service_healthy

# ── Indexer ──────────────────────────────────────────────────────────────────
# B-54: Runs the Soroban event-polling loop as a managed process separate
# from the API server, sharing the same compiled Docker image.
Expand Down
11 changes: 11 additions & 0 deletions frontend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
node_modules
.next
out
coverage
storybook-static
test-results
playwright-report
.env*.local
npm-debug.log*
Dockerfile
.dockerignore
58 changes: 58 additions & 0 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# syntax=docker/dockerfile:1
# ── BOXMEOUT frontend production image ───────────────────────────────────────
# Multi-stage build on the Next.js "standalone" output. The final image runs as
# an unprivileged user and contains only the traced server bundle.
#
# NEXT_PUBLIC_* values are inlined into the JavaScript bundle at build time, so
# they are passed as build args rather than runtime env. The defaults below
# target the local docker-compose stack; override them for other environments:
# docker build --build-arg NEXT_PUBLIC_API_URL=https://api.example.com .
# ─────────────────────────────────────────────────────────────────────────────

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:20-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1

ARG NEXT_PUBLIC_API_URL=http://localhost:3001
ARG NEXT_PUBLIC_STELLAR_NETWORK=testnet
ARG NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
ARG NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
ARG NEXT_PUBLIC_MARKET_CONTRACT_ID=
ARG NEXT_PUBLIC_ADMIN_ADDRESSES=
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
NEXT_PUBLIC_STELLAR_NETWORK=$NEXT_PUBLIC_STELLAR_NETWORK \
NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE=$NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE \
NEXT_PUBLIC_SOROBAN_RPC_URL=$NEXT_PUBLIC_SOROBAN_RPC_URL \
NEXT_PUBLIC_MARKET_CONTRACT_ID=$NEXT_PUBLIC_MARKET_CONTRACT_ID \
NEXT_PUBLIC_ADMIN_ADDRESSES=$NEXT_PUBLIC_ADMIN_ADDRESSES

COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN mkdir -p public && npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0

RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs

COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/ > /dev/null || exit 1

CMD ["node", "server.js"]
46 changes: 46 additions & 0 deletions frontend/__tests__/lib/resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
DISPUTE_WINDOW_MS,
getClaimBlockReason,
getResolutionStage,
} from "@/lib/resolution";

const RESOLVED_AT = "2026-10-01T12:00:00Z";
const RESOLVED_AT_MS = new Date(RESOLVED_AT).getTime();

function market(status: "Open" | "Locked" | "Resolved" | "Disputed" | "Cancelled", resolvedAt?: string) {
return { status, scheduledAt: "2026-09-30T00:00:00Z", resolvedAt };
}

describe("getResolutionStage", () => {
it("maps open, locked, disputed and cancelled markets directly", () => {
expect(getResolutionStage(market("Open"))).toBe("open");
expect(getResolutionStage(market("Locked"))).toBe("locked");
expect(getResolutionStage(market("Disputed", RESOLVED_AT))).toBe("disputed");
expect(getResolutionStage(market("Cancelled"))).toBe("cancelled");
});

it("keeps a resolved market in the dispute window until 24 hours have passed", () => {
const m = market("Resolved", RESOLVED_AT);
expect(getResolutionStage(m, RESOLVED_AT_MS + DISPUTE_WINDOW_MS - 1)).toBe("dispute-window");
expect(getResolutionStage(m, RESOLVED_AT_MS + DISPUTE_WINDOW_MS)).toBe("finalized");
});

it("falls back to scheduledAt when resolvedAt is missing", () => {
const m = market("Resolved");
const scheduledMs = new Date("2026-09-30T00:00:00Z").getTime();
expect(getResolutionStage(m, scheduledMs + 1000)).toBe("dispute-window");
expect(getResolutionStage(m, scheduledMs + DISPUTE_WINDOW_MS + 1)).toBe("finalized");
});
});

describe("getClaimBlockReason", () => {
it("explains the wait during the dispute window and on disputed markets", () => {
expect(getClaimBlockReason("dispute-window")).toMatch(/dispute window/);
expect(getClaimBlockReason("disputed")).toMatch(/admin/);
});

it("returns null when a claim can go ahead or the market is cancelled", () => {
expect(getClaimBlockReason("finalized")).toBeNull();
expect(getClaimBlockReason("cancelled")).toBeNull();
});
});
81 changes: 81 additions & 0 deletions frontend/__tests__/lib/security-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
buildConnectSrc,
buildSecurityHeaders,
HORIZON_URLS,
} from "@/lib/security-headers";

const API_URL = "http://localhost:3001";

function headerValue(headers: { key: string; value: string }[], key: string): string {
const header = headers.find((h) => h.key === key);
if (!header) throw new Error(`missing header ${key}`);
return header.value;
}

function directive(csp: string, name: string): string[] {
const match = csp.split("; ").find((d) => d.startsWith(`${name} `));
if (!match) throw new Error(`missing directive ${name}`);
return match.split(" ").slice(1);
}

describe("buildConnectSrc", () => {
it("allows the API, Horizon for the network and the Soroban RPC", () => {
const sources = buildConnectSrc({
apiUrl: "https://api.boxmeout.example/v1",
sorobanRpcUrl: "https://soroban-testnet.stellar.org",
network: "testnet",
});

expect(sources).toEqual([
"'self'",
"https://api.boxmeout.example",
HORIZON_URLS.testnet,
"https://soroban-testnet.stellar.org",
]);
});

it("omits the RPC origin when none is configured", () => {
const sources = buildConnectSrc({ apiUrl: API_URL, network: "mainnet" });

expect(sources).toEqual(["'self'", API_URL, HORIZON_URLS.mainnet]);
});

it("names the variable when the API URL is invalid", () => {
expect(() =>
buildConnectSrc({ apiUrl: "not a url", network: "testnet" })
).toThrow("NEXT_PUBLIC_API_URL must be a valid URL");
});
});

describe("buildSecurityHeaders", () => {
it("sets the clickjacking, referrer, sniffing and permissions headers", () => {
const headers = buildSecurityHeaders({ isDev: false, apiUrl: API_URL, network: "testnet" });

expect(headerValue(headers, "X-Frame-Options")).toBe("DENY");
expect(headerValue(headers, "Referrer-Policy")).toBe("strict-origin-when-cross-origin");
expect(headerValue(headers, "X-Content-Type-Options")).toBe("nosniff");
expect(headerValue(headers, "Permissions-Policy")).toContain("camera=()");
});

it("builds connect-src from the configured API origin", () => {
const headers = buildSecurityHeaders({ isDev: false, apiUrl: API_URL, network: "testnet" });
const csp = headerValue(headers, "Content-Security-Policy");

expect(directive(csp, "connect-src")).toContain("http://localhost:3001");
expect(directive(csp, "frame-ancestors")).toEqual(["'none'"]);
});

it("allows eval in scripts only in development", () => {
const prod = headerValue(
buildSecurityHeaders({ isDev: false, apiUrl: API_URL, network: "testnet" }),
"Content-Security-Policy"
);
const dev = headerValue(
buildSecurityHeaders({ isDev: true, apiUrl: API_URL, network: "testnet" }),
"Content-Security-Policy"
);

expect(directive(prod, "script-src")).not.toContain("'unsafe-eval'");
expect(directive(dev, "script-src")).toContain("'unsafe-eval'");
});
});
42 changes: 42 additions & 0 deletions frontend/__tests__/lib/sitemap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { buildSitemapEntries } from "@/lib/sitemap";

const NOW = new Date("2026-10-19T00:00:00Z");

describe("buildSitemapEntries", () => {
it("lists the home page first, at the site origin", () => {
const entries = buildSitemapEntries("https://boxmeout.example/some/path", [], NOW);

expect(entries).toEqual([
{ url: "https://boxmeout.example/", lastModified: NOW, changeFrequency: "hourly", priority: 1 },
]);
});

it("adds one absolute URL per open market", () => {
const entries = buildSitemapEntries(
"https://boxmeout.example",
[{ id: "m-1", scheduledAt: "2026-11-01T00:00:00Z" }],
NOW
);

expect(entries[1]).toEqual({
url: "https://boxmeout.example/markets/m-1",
lastModified: new Date("2026-11-01T00:00:00Z"),
changeFrequency: "hourly",
priority: 0.8,
});
});

it("encodes market ids so they cannot change the path", () => {
const entries = buildSitemapEntries(
"https://boxmeout.example",
[{ id: "a/b?c", scheduledAt: "2026-11-01T00:00:00Z" }],
NOW
);

expect(entries[1].url).toBe("https://boxmeout.example/markets/a%2Fb%3Fc");
});

it("rejects a site URL that is not absolute", () => {
expect(() => buildSitemapEntries("boxmeout.example", [], NOW)).toThrow();
});
});
1 change: 1 addition & 0 deletions frontend/app/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 0 additions & 1 deletion frontend/app/markets/[id]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
/* eslint-disable @next/next/no-img-element */
// @ts-expect-error -- next/og types resolve after npm install
import { ImageResponse } from "next/og";
import { fetchMarketById } from "@/lib/api";

Expand Down
3 changes: 2 additions & 1 deletion frontend/components/BetForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
import { useEffect, useId, useMemo, useState } from "react";
import { Bet, BetSide, Market } from "@/lib/api";
import { BetAmountInput } from "./BetAmountInput";
import { TransactionStatusModal, TransactionStatus } from "./TransactionStatusModal";
import { TransactionStatusModal } from "./TransactionStatusModal";
import type { TransactionStatus } from "@/lib/stellar";
import { usePlaceBet } from "@/hooks/usePlaceBet";

const MIN_AMOUNT_XLM = 1;
Expand Down
93 changes: 93 additions & 0 deletions frontend/components/ClaimButton.resolution.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { ClaimButton } from "./ClaimButton";
import { Bet, Market, MarketStatus, Outcome } from "@/lib/api";

jest.mock("@/hooks/useClaimWinnings", () => ({
useClaimWinnings: () => ({ claim: jest.fn() }),
}));
jest.mock("@/hooks/useClaimRefund", () => ({
useClaimRefund: () => ({ claimRefund: jest.fn() }),
}));

const RESOLVED_AT = "2026-10-01T12:00:00Z";
const RESOLVED_AT_MS = new Date(RESOLVED_AT).getTime();
const DAY_MS = 24 * 60 * 60 * 1000;

function makeMarket(status: MarketStatus, outcome: Outcome | null): Market {
return {
id: "m-1",
contractAddress: "C1",
fighterA: { name: "Maya Chen", record: "10-0", nationality: "US", weightClass: "Welter" },
fighterB: { name: "Rico Alvarez", record: "9-1", nationality: "MX", weightClass: "Welter" },
scheduledAt: "2026-09-30T00:00:00Z",
bettingEndsAt: "2026-09-29T23:00:00Z",
status,
outcome,
poolA: "0",
poolB: "0",
totalPool: "0",
oracleAddress: "G1",
createdBy: "G2",
resolvedAt: RESOLVED_AT,
};
}

const winningBet: Bet = {
id: "b-1",
marketId: "m-1",
bettor: "GBETTOR",
side: "FighterA",
amount: "100",
placedAt: "2026-09-29T00:00:00Z",
claimed: false,
payout: null,
};

describe("ClaimButton", () => {
it("renders nothing for a losing bet on a resolved market", () => {
const { container } = render(
<ClaimButton bet={{ ...winningBet, side: "FighterB" }} market={makeMarket("Resolved", "FighterA")} onClaimed={jest.fn()} />
);
expect(container).toBeEmptyDOMElement();
});

it("shows a disabled claim with the reason during the dispute window", () => {
jest.spyOn(Date, "now").mockReturnValue(RESOLVED_AT_MS + 1000);
render(<ClaimButton bet={winningBet} market={makeMarket("Resolved", "FighterA")} onClaimed={jest.fn()} />);

expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeDisabled();
expect(screen.getByText("Claimable after the dispute window closes.")).toBeInTheDocument();
jest.restoreAllMocks();
});

it("shows a disabled claim with the reason while the result is disputed", () => {
render(<ClaimButton bet={winningBet} market={makeMarket("Disputed", "FighterA")} onClaimed={jest.fn()} />);

expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeDisabled();
expect(screen.getByText(/decided by an admin/)).toBeInTheDocument();
});

it("enables the claim once the dispute window has passed", () => {
jest.spyOn(Date, "now").mockReturnValue(RESOLVED_AT_MS + DAY_MS + 1);
render(<ClaimButton bet={winningBet} market={makeMarket("Resolved", "FighterA")} onClaimed={jest.fn()} />);

expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeEnabled();
expect(screen.queryByText(/dispute window/)).not.toBeInTheDocument();
jest.restoreAllMocks();
});

it("allows refunds on a cancelled market straight away", () => {
render(<ClaimButton bet={winningBet} market={makeMarket("Cancelled", null)} onClaimed={jest.fn()} />);

expect(screen.getByRole("button", { name: "Claim Refund" })).toBeEnabled();
});

it("shows a claimed bet as claimed", () => {
render(
<ClaimButton bet={{ ...winningBet, claimed: true }} market={makeMarket("Resolved", "FighterA")} onClaimed={jest.fn()} />
);

expect(screen.getByRole("button", { name: "Claimed" })).toBeDisabled();
});
});
Loading
Loading