diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index c0f4082..9fba033 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -25,37 +25,305 @@ body { } .page { - max-width: 56rem; + max-width: 64rem; margin: 0 auto; - padding: 3rem 1.25rem 5rem; + padding: 2.5rem 1.25rem 4rem; + display: flex; + flex-direction: column; + gap: 4.5rem; +} + +/* Shared section heading */ +.section-heading { + font-size: clamp(1.4rem, 4vw, 1.9rem); + font-weight: 800; + letter-spacing: 0.01em; + margin: 0 0 1.5rem; + text-align: center; +} + +/* Status badges (live / partial / coming soon) */ +.badge { + display: inline-block; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 0.2rem 0.55rem; + border-radius: 999px; + white-space: nowrap; + border: 1px solid transparent; +} +.badge--live { + color: #7be3a3; + background: rgba(60, 200, 120, 0.12); + border-color: rgba(60, 200, 120, 0.35); +} +.badge--partial { + color: var(--color-ember-bright); + background: rgba(255, 138, 61, 0.12); + border-color: rgba(255, 138, 61, 0.35); +} +.badge--soon { + color: var(--color-smoke); + background: rgba(184, 169, 156, 0.1); + border-color: rgba(184, 169, 156, 0.3); } /* Hero / header */ .hero { + position: relative; text-align: center; - margin-bottom: 2.5rem; + padding: 3rem 0 1rem; + overflow: visible; +} +.hero__glow { + position: absolute; + top: -6rem; + left: 50%; + width: min(40rem, 90vw); + height: 28rem; + transform: translateX(-50%); + background: radial-gradient( + 50% 50% at 50% 50%, + rgba(255, 87, 34, 0.28), + transparent 70% + ); + filter: blur(20px); + pointer-events: none; + z-index: 0; +} +.hero > *:not(.hero__glow) { + position: relative; + z-index: 1; +} +.hero__eyebrow { + text-transform: uppercase; + letter-spacing: 0.32em; + font-size: 0.72rem; + font-weight: 600; + color: var(--color-smoke); + margin: 0 0 0.75rem; } .hero__title { - font-size: clamp(3rem, 10vw, 5rem); - font-weight: 800; + font-size: clamp(3.5rem, 13vw, 6.5rem); + font-weight: 900; letter-spacing: 0.15em; + line-height: 1; margin: 0; background: linear-gradient(180deg, var(--color-ember-bright), var(--color-ember)); -webkit-background-clip: text; background-clip: text; color: transparent; - text-shadow: 0 0 40px rgba(255, 87, 34, 0.35); + text-shadow: 0 0 60px rgba(255, 87, 34, 0.45); } .hero__tagline { - font-size: 1.15rem; - font-weight: 600; + font-size: clamp(1.1rem, 3.5vw, 1.45rem); + font-weight: 700; color: var(--color-ember-bright); - margin: 0.75rem 0 0.25rem; + margin: 1rem 0 0.75rem; +} +.hero__value { + max-width: 38rem; + margin: 0 auto 0.5rem; + font-size: clamp(1rem, 2.5vw, 1.15rem); + color: #f5ede6; } .hero__trust { color: var(--color-smoke); + font-style: italic; margin: 0; } +.hero__cta { + display: flex; + flex-wrap: wrap; + gap: 0.85rem; + justify-content: center; + align-items: center; + margin: 1.75rem 0 0.85rem; +} +.hero__secondary { + color: var(--color-ember-bright); + text-decoration: none; + font-weight: 600; + font-size: 0.95rem; + padding: 0 1rem; + height: 48px; + display: inline-flex; + align-items: center; + border-radius: 0.5rem; + border: 1px solid rgba(255, 138, 61, 0.35); + transition: background 0.15s ease, border-color 0.15s ease; +} +.hero__secondary:hover { + background: rgba(255, 138, 61, 0.08); + border-color: rgba(255, 138, 61, 0.6); +} +.hero__note { + color: var(--color-smoke); + font-size: 0.85rem; + margin: 0; +} + +/* Scanner section */ +.scanner__head { + margin-bottom: 1.5rem; +} +.scanner__sub { + text-align: center; + color: var(--color-smoke); + margin: -0.75rem auto 0; + max-width: 34rem; +} +.scanner__panel { + border: 1px solid rgba(255, 138, 61, 0.22); + background: linear-gradient(180deg, rgba(255, 87, 34, 0.06), rgba(26, 20, 18, 0.6)); + border-radius: 1rem; + padding: 1.75rem 1.5rem; +} +.scanner__loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + margin-top: 1.25rem; + color: var(--color-smoke); +} +.scanner__spinner { + width: 1.05rem; + height: 1.05rem; + border-radius: 50%; + border: 2px solid rgba(255, 138, 61, 0.3); + border-top-color: var(--color-ember-bright); + animation: pyre-spin 0.8s linear infinite; +} +@keyframes pyre-spin { + to { + transform: rotate(360deg); + } +} + +/* How it works */ +.how__grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + gap: 1rem; +} +.how__step { + border: 1px solid rgba(255, 255, 255, 0.08); + background: var(--color-coal); + border-radius: 0.85rem; + padding: 1.25rem 1.25rem 1.5rem; +} +.how__num { + font-size: 1.6rem; + font-weight: 900; + color: rgba(255, 138, 61, 0.5); + font-variant-numeric: tabular-nums; +} +.how__title { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + font-size: 1.05rem; + font-weight: 700; + margin: 0.35rem 0 0.45rem; +} +.how__body { + color: var(--color-smoke); + font-size: 0.9rem; + margin: 0; + line-height: 1.5; +} + +/* Feature / status cards */ +.features__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: 1rem; +} +.feature-card { + border: 1px solid rgba(255, 255, 255, 0.08); + background: var(--color-coal); + border-radius: 0.85rem; + padding: 1.25rem 1.35rem 1.4rem; +} +.feature-card--live { + border-color: rgba(60, 200, 120, 0.3); +} +.feature-card--partial { + border-color: rgba(255, 138, 61, 0.3); +} +.feature-card__top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.5rem; +} +.feature-card__title { + font-size: 1.05rem; + font-weight: 700; + margin: 0; +} +.feature-card__body { + color: var(--color-smoke); + font-size: 0.9rem; + line-height: 1.5; + margin: 0; +} + +/* Footer */ +.footer { + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding-top: 2.5rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + text-align: center; +} +.footer__brand { + display: flex; + flex-direction: column; + gap: 0.25rem; +} +.footer__wordmark { + font-size: 1.4rem; + font-weight: 900; + letter-spacing: 0.15em; + color: var(--color-ember-bright); +} +.footer__motto { + color: var(--color-smoke); + font-size: 0.85rem; +} +.footer__links { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + justify-content: center; +} +.footer__links a { + color: #f5ede6; + text-decoration: none; + font-weight: 600; + font-size: 0.92rem; +} +.footer__links a:hover { + color: var(--color-ember-bright); +} +.footer__disclaimer { + max-width: 40rem; + color: var(--color-smoke); + font-size: 0.8rem; + line-height: 1.6; + margin: 0.5rem 0 0; +} /* Connect / scan controls */ .connect { @@ -64,7 +332,6 @@ body { gap: 0.75rem; justify-content: center; align-items: center; - margin-bottom: 1.5rem; } .scan-btn { diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index ba039f5..628af82 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,204 +1,22 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; import { useWallet } from "@solana/wallet-adapter-react"; -import { WalletMultiButton } from "@solana/wallet-adapter-react-ui"; -import { TokenClassification } from "@pyre/core"; -import type { ScanResponse, TokenAccountDto } from "@pyre/core"; - -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000"; - -// Display order + friendly headings. Wording rules: never say "safe"; protected / -// unsupported read as "skipped / not eligible"; eligible reads as "appears eligible". -const SECTIONS: ReadonlyArray<{ - classification: TokenClassification; - heading: string; - blurb: string; -}> = [ - { - classification: TokenClassification.EMPTY_CLOSE_ONLY, - heading: "Closeable empty accounts", - blurb: "Empty token accounts that appear eligible to close and reclaim rent.", - }, - { - classification: TokenClassification.INCINERATE_ONLY, - heading: "Burnable junk", - blurb: "Worthless leftover balances that appear eligible to burn.", - }, - { - classification: TokenClassification.TRANSMUTABLE, - heading: "Transmutable scraps", - blurb: "Scraps that appear eligible to feed the fire.", - }, - { - classification: TokenClassification.PROTECTED_SKIP, - heading: "Protected / skipped", - blurb: "Skipped — not eligible for cleanup. Left untouched.", - }, - { - classification: TokenClassification.UNSUPPORTED, - heading: "Unsupported", - blurb: "Not eligible — PYRE can't reason about these, so they're skipped.", - }, -]; - -function truncate(addr: string): string { - if (addr.length <= 10) return addr; - return `${addr.slice(0, 4)}…${addr.slice(-4)}`; -} - -function lamportsToSol(lamports: string): number { - // Lamports arrive as a u64 string; BigInt avoids precision loss before scaling. - try { - return Number(BigInt(lamports)) / 1e9; - } catch { - return 0; - } -} - -function AccountRow({ account }: { account: TokenAccountDto }) { - const label = account.symbol ?? account.name ?? truncate(account.mint); - return ( -
  • -
    - - {label} - - - {truncate(account.mint)} - - {account.uiBalance} -
    - {account.warnings.length > 0 && ( - - )} -
  • - ); -} +import { Hero } from "../components/Hero"; +import { Scanner } from "../components/Scanner"; +import { HowItWorks } from "../components/HowItWorks"; +import { Features } from "../components/Features"; +import { Footer } from "../components/Footer"; export default function HomePage() { - const { publicKey } = useWallet(); - const [scan, setScan] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const wallet = publicKey?.toBase58() ?? null; - - const runScan = useCallback(async () => { - if (!wallet) return; - setLoading(true); - setError(null); - setScan(null); - try { - const res = await fetch(`${API_URL}/api/scan`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ wallet }), - }); - if (!res.ok) { - throw new Error(`Scan failed (${res.status})`); - } - const data = (await res.json()) as ScanResponse; - setScan(data); - } catch (e) { - setError(e instanceof Error ? e.message : "Scan failed."); - } finally { - setLoading(false); - } - }, [wallet]); - - const grouped = useMemo(() => { - const map = new Map(); - if (scan) { - for (const acct of scan.accounts) { - const bucket = map.get(acct.classification) ?? []; - bucket.push(acct); - map.set(acct.classification, bucket); - } - } - return map; - }, [scan]); - - const reclaimSol = scan ? lamportsToSol(scan.summary.estimatedRentLamports) : 0; + const { connected } = useWallet(); return (
    -
    -

    PYRE

    -

    - Burn the dead. Feed the PYRE. Claim the Spawn. -

    -

    - PYRE returns your rent. The scraps feed the fire. -

    -
    - -
    - - {wallet && ( - - )} -
    - - {!wallet && ( -

    Connect a wallet to scan it for reclaimable rent.

    - )} - - {error &&

    Something went wrong: {error}

    } - - {scan && ( -
    -
    -

    - Recover ~{reclaimSol.toFixed(6)} SOL from{" "} - {scan.summary.emptyCloseOnly} empty account - {scan.summary.emptyCloseOnly === 1 ? "" : "s"}. -

    -

    - Scanned {scan.summary.totalAccounts} token account - {scan.summary.totalAccounts === 1 ? "" : "s"}. -

    -
    - - {SECTIONS.map(({ classification, heading, blurb }) => { - const accounts = grouped.get(classification) ?? []; - if (accounts.length === 0) return null; - return ( -
    -

    - {heading}{" "} - - ({accounts.length}) - -

    -

    {blurb}

    -
      - {accounts.map((a) => ( - - ))} -
    -
    - ); - })} - -

    - This is a scan and preview only — nothing has been signed. Closing - empty accounts and burning junk (which require signing in your - wallet) comes next. -

    -
    - )} + + + + +
    ); } diff --git a/apps/web/src/components/Features.tsx b/apps/web/src/components/Features.tsx new file mode 100644 index 0000000..8d18c53 --- /dev/null +++ b/apps/web/src/components/Features.tsx @@ -0,0 +1,68 @@ +/** + * Feature / status cards. HONEST about what is live vs coming soon — coming-soon + * items carry a clear badge and must not imply they work today. + * Server component (static content, no hooks). + */ +type Status = "live" | "partial" | "soon"; + +const FEATURES: ReadonlyArray<{ + title: string; + body: string; + status: Status; + statusLabel: string; +}> = [ + { + title: "Scan & classify", + body: "Read your SPL token accounts and conservatively classify every one. Unknown means skip.", + status: "live", + statusLabel: "Live", + }, + { + title: "Reclaim ATA rent", + body: "The scan already estimates recoverable rent from empty accounts. One-click close to send that SOL back to you is next.", + status: "partial", + statusLabel: "Scan live · close soon", + }, + { + title: "Burn dead tokens", + body: "Burn worthless leftover balances to zero, then close the emptied accounts to recover their rent.", + status: "soon", + statusLabel: "Phase 3", + }, + { + title: "Prometheus AI meme rebirth", + body: "Turn burned remnants into an AI-generated meme Spawn for human-reviewed launch. Entertainment, not a promise of value.", + status: "soon", + statusLabel: "Coming soon", + }, +]; + +function badgeClass(status: Status): string { + if (status === "live") return "badge badge--live"; + if (status === "partial") return "badge badge--partial"; + return "badge badge--soon"; +} + +export function Features() { + return ( +
    +

    + What's live, what's coming +

    +
    + {FEATURES.map(({ title, body, status, statusLabel }) => ( +
    +
    +

    {title}

    + {statusLabel} +
    +

    {body}

    +
    + ))} +
    +
    + ); +} diff --git a/apps/web/src/components/Footer.tsx b/apps/web/src/components/Footer.tsx new file mode 100644 index 0000000..1076225 --- /dev/null +++ b/apps/web/src/components/Footer.tsx @@ -0,0 +1,33 @@ +/** + * Footer: dev status tracker (served separately by nginx as a static /status + * page — plain , NOT a Next route), repo link, and the §2 disclaimer. + * Server component. + */ +const REPO_URL = "https://git.lumiai.dev/RogueWave/pyre"; + +export function Footer() { + return ( + + ); +} diff --git a/apps/web/src/components/Hero.tsx b/apps/web/src/components/Hero.tsx new file mode 100644 index 0000000..e44d822 --- /dev/null +++ b/apps/web/src/components/Hero.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { WalletMultiButton } from "@solana/wallet-adapter-react-ui"; + +/** + * Landing hero: wordmark, tagline, value prop, trust line, and the primary + * wallet call-to-action. Wording rules apply — this is a scan/preview only. + */ +export function Hero({ connected }: { connected: boolean }) { + return ( +
    +
    + ); +} diff --git a/apps/web/src/components/HowItWorks.tsx b/apps/web/src/components/HowItWorks.tsx new file mode 100644 index 0000000..083fcfd --- /dev/null +++ b/apps/web/src/components/HowItWorks.tsx @@ -0,0 +1,55 @@ +/** + * "How it works" — a 3-4 step visual walking the user from connect to reclaim. + * Honest about phase boundaries: closing/burning is flagged as coming soon. + * Server component (no hooks / interactivity). + */ +const STEPS: ReadonlyArray<{ + step: string; + title: string; + body: string; + soon?: boolean; +}> = [ + { + step: "01", + title: "Connect", + body: "Connect a Solana wallet. PYRE never holds your keys — every action stays client-side.", + }, + { + step: "02", + title: "Scan", + body: "PYRE reads your SPL token accounts and estimates the SOL rent locked inside empty ones.", + }, + { + step: "03", + title: "Review classifications", + body: "Each account is conservatively grouped — closeable, burnable, transmutable, or skipped / not eligible.", + }, + { + step: "04", + title: "Reclaim rent", + body: "Close empty accounts and burn dead tokens to recover rent to your own wallet.", + soon: true, + }, +]; + +export function HowItWorks() { + return ( +
    +

    + How it works +

    +
      + {STEPS.map(({ step, title, body, soon }) => ( +
    1. + {step} +

      + {title} + {soon && Coming soon} +

      +

      {body}

      +
    2. + ))} +
    +
    + ); +} diff --git a/apps/web/src/components/Scanner.tsx b/apps/web/src/components/Scanner.tsx new file mode 100644 index 0000000..a9fa92f --- /dev/null +++ b/apps/web/src/components/Scanner.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { useWallet } from "@solana/wallet-adapter-react"; +import { WalletMultiButton } from "@solana/wallet-adapter-react-ui"; +import { TokenClassification } from "@pyre/core"; +import type { ScanResponse, TokenAccountDto } from "@pyre/core"; + +// Same-origin by default so production hits "/api/scan" behind the same host. +// Override with NEXT_PUBLIC_API_URL only when the API lives elsewhere (e.g. dev). +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ""; + +// Display order + friendly headings. Wording rules: never say "safe"; protected / +// unsupported read as "skipped / not eligible"; eligible reads as "appears eligible". +const SECTIONS: ReadonlyArray<{ + classification: TokenClassification; + heading: string; + blurb: string; +}> = [ + { + classification: TokenClassification.EMPTY_CLOSE_ONLY, + heading: "Closeable empty accounts", + blurb: "Empty token accounts that appear eligible to close and reclaim rent.", + }, + { + classification: TokenClassification.INCINERATE_ONLY, + heading: "Burnable junk", + blurb: "Worthless leftover balances that appear eligible to burn.", + }, + { + classification: TokenClassification.TRANSMUTABLE, + heading: "Transmutable scraps", + blurb: "Scraps that appear eligible to feed the fire.", + }, + { + classification: TokenClassification.PROTECTED_SKIP, + heading: "Protected / skipped", + blurb: "Skipped — not eligible for cleanup. Left untouched.", + }, + { + classification: TokenClassification.UNSUPPORTED, + heading: "Unsupported", + blurb: "Not eligible — PYRE can't reason about these, so they're skipped.", + }, +]; + +function truncate(addr: string): string { + if (addr.length <= 10) return addr; + return `${addr.slice(0, 4)}…${addr.slice(-4)}`; +} + +function lamportsToSol(lamports: string): number { + // Lamports arrive as a u64 string; BigInt avoids precision loss before scaling. + try { + return Number(BigInt(lamports)) / 1e9; + } catch { + return 0; + } +} + +function AccountRow({ account }: { account: TokenAccountDto }) { + const label = account.symbol ?? account.name ?? truncate(account.mint); + return ( +
  • +
    + + {label} + + + {truncate(account.mint)} + + {account.uiBalance} +
    + {account.warnings.length > 0 && ( +
      + {account.warnings.map((w, i) => ( +
    • ⚠ {w}
    • + ))} +
    + )} +
  • + ); +} + +export function Scanner() { + const { publicKey } = useWallet(); + const [scan, setScan] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const wallet = publicKey?.toBase58() ?? null; + + const runScan = useCallback(async () => { + if (!wallet) return; + setLoading(true); + setError(null); + setScan(null); + try { + const res = await fetch(`${API_BASE}/api/scan`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ wallet }), + }); + if (!res.ok) { + throw new Error(`Scan failed (${res.status})`); + } + const data = (await res.json()) as ScanResponse; + setScan(data); + } catch (e) { + setError(e instanceof Error ? e.message : "Scan failed."); + } finally { + setLoading(false); + } + }, [wallet]); + + const grouped = useMemo(() => { + const map = new Map(); + if (scan) { + for (const acct of scan.accounts) { + const bucket = map.get(acct.classification) ?? []; + bucket.push(acct); + map.set(acct.classification, bucket); + } + } + return map; + }, [scan]); + + const reclaimSol = scan ? lamportsToSol(scan.summary.estimatedRentLamports) : 0; + + return ( +
    +
    +

    + Live scanner +

    +

    + Connect a wallet and run a read-only scan for reclaimable rent. + Nothing is signed. +

    +
    + +
    +
    + + {wallet && ( + + )} +
    + + {!wallet && ( +

    + Connect a wallet above to scan it for reclaimable rent. +

    + )} + + {loading && ( +
    +
    + )} + + {error && ( +

    + Something went wrong: {error} +

    + )} +
    + + {scan && ( +
    +
    +

    + Recover ~{reclaimSol.toFixed(6)} SOL from{" "} + {scan.summary.emptyCloseOnly} empty account + {scan.summary.emptyCloseOnly === 1 ? "" : "s"}. +

    +

    + Scanned {scan.summary.totalAccounts} token account + {scan.summary.totalAccounts === 1 ? "" : "s"}. +

    +
    + + {SECTIONS.map(({ classification, heading, blurb }) => { + const accounts = grouped.get(classification) ?? []; + if (accounts.length === 0) return null; + return ( +
    +

    + {heading}{" "} + + ({accounts.length}) + +

    +

    {blurb}

    +
      + {accounts.map((a) => ( + + ))} +
    +
    + ); + })} + +

    + This is a scan and preview only — nothing has been signed. Closing + empty accounts and burning junk (which require signing in your + wallet) comes next. +

    +
    + )} +
    + ); +} diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index b2351f8..415f1fe 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -1,74 +1,82 @@ -// PYRE / Prometheus Protocol — PM2 ecosystem (process manager) config -// -// ⚠️ INERT / NOT YET RUNNABLE: the apps are NOT implemented yet. This file -// defines how the three PYRE processes WILL run once apps/web, apps/api, -// and apps/worker are built/deployed. Starting it now will fail because -// the apps (and their builds) do not exist yet. +// PYRE / Prometheus Protocol — PM2 ecosystem (process manager) config. // // Process names match docs/PYRE_MVP_DESIGN.md §12: pyre-web, pyre-api, pyre-worker. -// -// Once apps exist, start with: -// pm2 start ecosystem.config.cjs -// pm2 save # persist process list so `pm2 resurrect` works on boot +// Start (from repo root): pm2 start ecosystem.config.cjs --only pyre-api,pyre-web +// Persist for boot: pm2 save (systemd unit infra/systemd/pm2-pyre.service +// runs `pm2 resurrect` on boot) // // PM2 is installed at user level: ~/.local/share/pnpm/bin/pm2 // Logs go to /home/pyre/pyre/logs/ (rotated by infra/logrotate/pyre). +// Each process is capped at 400M (8GB VPS shared with postgres/redis/nginx). // -// Note on memory: the 8GB VPS is shared with postgres, redis, nginx, etc., -// so each process is capped at 400M via max_memory_restart. +// NOTE: api/worker run their TypeScript directly via `node --import tsx` (no +// separate build step; tsx resolves the workspace `@pyre/*` source packages). +// The web app runs Next.js in production mode and requires a prior `next build`. + +const REPO = __dirname; module.exports = { apps: [ { // Next.js frontend (production) — port 3000 per .env.example (WEB_PORT). + // Requires `pnpm --filter @pyre/web build` first. name: "pyre-web", - cwd: "apps/web", - script: "pnpm", - args: "start", // runs `next start` (requires a prior `pnpm build`) + cwd: `${REPO}/apps/web`, + script: "node_modules/next/dist/bin/next", + args: "start", instances: 1, + exec_mode: "fork", autorestart: true, - max_memory_restart: "400M", + max_memory_restart: "500M", env: { NODE_ENV: "production", PORT: 3000, + HOSTNAME: "127.0.0.1", }, - out_file: "/home/pyre/pyre/logs/pyre-web-out.log", - error_file: "/home/pyre/pyre/logs/pyre-web-err.log", + out_file: `${REPO}/logs/pyre-web-out.log`, + error_file: `${REPO}/logs/pyre-web-err.log`, }, { // Fastify HTTP API — port 4000 per .env.example (API_PORT). - // Runs the compiled server. Until a build exists you can temporarily - // swap to a dev runner: script: "pnpm", args: "dev" + // Runs TS directly via tsx (resolves workspace @pyre/* source). name: "pyre-api", - cwd: "apps/api", - script: "node", - args: "dist/index.js", + cwd: `${REPO}/apps/api`, + script: "src/index.ts", + interpreter: "node", + interpreter_args: "--import tsx", instances: 1, + exec_mode: "fork", autorestart: true, max_memory_restart: "400M", env: { NODE_ENV: "production", PORT: 4000, + HOST: "127.0.0.1", + WEB_PUBLIC_URL: "https://feedthepyre.com", + // Public RPC by default — override with a Helius/Triton/QuickNode URL + // (set SOLANA_RPC_URL in the environment) to avoid mainnet rate limits. + SOLANA_RPC_URL: "https://api.mainnet-beta.solana.com", }, - out_file: "/home/pyre/pyre/logs/pyre-api-out.log", - error_file: "/home/pyre/pyre/logs/pyre-api-err.log", + out_file: `${REPO}/logs/pyre-api-out.log`, + error_file: `${REPO}/logs/pyre-api-err.log`, }, { - // BullMQ background worker (no HTTP port). - // Runs the compiled worker. Until a build exists you can temporarily - // swap to a dev runner: script: "pnpm", args: "dev" + // BullMQ background worker (no HTTP port). Not started by default in + // Phase 1 (no jobs implemented yet). Runs TS via tsx when enabled. name: "pyre-worker", - cwd: "apps/worker", - script: "node", - args: "dist/index.js", + cwd: `${REPO}/apps/worker`, + script: "src/index.ts", + interpreter: "node", + interpreter_args: "--import tsx", instances: 1, + exec_mode: "fork", autorestart: true, max_memory_restart: "400M", env: { NODE_ENV: "production", }, - out_file: "/home/pyre/pyre/logs/pyre-worker-out.log", - error_file: "/home/pyre/pyre/logs/pyre-worker-err.log", + out_file: `${REPO}/logs/pyre-worker-out.log`, + error_file: `${REPO}/logs/pyre-worker-err.log`, }, ], }; diff --git a/infra/nginx/feedthepyre.com.conf b/infra/nginx/feedthepyre.com.conf index a3fbb11..4c1e03c 100644 --- a/infra/nginx/feedthepyre.com.conf +++ b/infra/nginx/feedthepyre.com.conf @@ -1,109 +1,63 @@ -# ============================================================================ -# PYRE / Prometheus Protocol — nginx virtual host for feedthepyre.com -# ---------------------------------------------------------------------------- -# Install path: /etc/nginx/sites-available/feedthepyre.com -# (the provision script symlinks this into sites-enabled/) +# ============================================================================= +# nginx vhost — feedthepyre.com +# ============================================================================= +# Serves the PYRE app at /, the API at /api, and the dev status tracker at +# /status. Installed to /etc/nginx/sites-available/feedthepyre.com by +# scripts/deploy-web.sh; certbot --nginx mirrors this server to a 443 block and +# adds the HTTP->HTTPS redirect. # -# TLS: Managed by certbot. Run `certbot --nginx` AFTER this config is -# installed — it will inject the listen 443 ssl server block, -# the ssl_certificate / ssl_certificate_key lines, and the -# HTTP->HTTPS redirect automatically. Do NOT hand-edit those in. -# -# App ports (see docs/PYRE_MVP_DESIGN.md §11 and .env.example): -# web (Next.js) -> 127.0.0.1:3000 (WEB_PORT) -# api (Fastify) -> 127.0.0.1:4000 (API_PORT) -# -# Current behaviour: serves the static status dashboard from -# /var/www/feedthepyre/status. The reverse-proxy blocks below -# are commented out until the apps are deployed. -# ============================================================================ +# Upstreams (pm2): web = 127.0.0.1:3000 (Next.js), api = 127.0.0.1:4000 (Fastify) +# ============================================================================= server { listen 80; listen [::]:80; server_name feedthepyre.com www.feedthepyre.com; - # --- Static status site (current site root) ----------------------------- - root /var/www/feedthepyre/status; - index index.html; - - # --- Logging ------------------------------------------------------------ access_log /var/log/nginx/feedthepyre.access.log; error_log /var/log/nginx/feedthepyre.error.log; - # --- ACME HTTP-01 challenge -------------------------------------------- - # Explicit so certbot's HTTP-01 validation works even before its --nginx - # tweaks are applied. ^~ ensures this wins over the regex/proxy locations. + gzip on; + gzip_proxied any; + gzip_types text/plain text/css application/json application/javascript + application/xml image/svg+xml; + + client_max_body_size 1m; + + # Let's Encrypt HTTP-01 (kept so cert renewals work). location ^~ /.well-known/acme-challenge/ { root /var/www/feedthepyre/status; allow all; } - # --- Basic hardening ---------------------------------------------------- - # gzip for text-ish content types. - gzip on; - gzip_comp_level 5; - gzip_min_length 256; - gzip_proxied any; - gzip_vary on; - gzip_types - text/plain - text/css - text/xml - text/javascript - application/javascript - application/json - application/xml - application/rss+xml - image/svg+xml; - - # NOTE: `server_tokens off;` is intentionally NOT set here — it belongs in - # the http{} block of /etc/nginx/nginx.conf so it applies globally. Set it - # there once rather than duplicating it per-vhost. - - # --- Site root ---------------------------------------------------------- - # Serve the static status dashboard for now. - # - # LATER: when apps/web (Next.js) is deployed, switch this location from the - # static status page to a reverse proxy. Replace the try_files body with: - # - # proxy_pass http://127.0.0.1:3000; - # proxy_http_version 1.1; - # proxy_set_header Host $host; - # proxy_set_header X-Real-IP $remote_addr; - # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # proxy_set_header X-Forwarded-Proto $scheme; - # proxy_set_header Upgrade $http_upgrade; - # proxy_set_header Connection $connection_upgrade; - # - location / { - try_files $uri $uri/ /index.html; + # --- Dev status tracker (static) -> /status ----------------------------- + location = /status { return 301 /status/; } + location /status/ { + alias /var/www/feedthepyre/status/; + index index.html; + try_files $uri $uri/ /status/index.html; } - # ------------------------------------------------------------------------ - # REVERSE-PROXY BLOCKS — enable when apps are running - # ------------------------------------------------------------------------ - # Uncomment the /api/ block below once apps/api (Fastify, port 4000) is up. - # The trailing slash on proxy_pass strips the /api/ prefix so the backend - # sees /scan, /receipt, etc. - # - # location /api/ { - # proxy_pass http://127.0.0.1:4000/; - # proxy_http_version 1.1; - # proxy_set_header Host $host; - # proxy_set_header X-Real-IP $remote_addr; - # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # proxy_set_header X-Forwarded-Proto $scheme; - # proxy_set_header Upgrade $http_upgrade; - # proxy_set_header Connection $connection_upgrade; - # } - # - # The websocket Upgrade/Connection headers above rely on a $connection_upgrade - # map. Add this once in the http{} block of /etc/nginx/nginx.conf: - # - # map $http_upgrade $connection_upgrade { - # default upgrade; - # '' close; - # } - # ------------------------------------------------------------------------ + # --- API (Fastify) ------------------------------------------------------ + # No trailing slash on proxy_pass: the /api/ prefix is preserved, so the + # backend receives /api/scan (its actual route). + location /api/ { + proxy_pass http://127.0.0.1:4000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + } + + # --- Web app (Next.js) -------------------------------------------------- + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } } diff --git a/scripts/deploy-web.sh b/scripts/deploy-web.sh new file mode 100755 index 0000000..8b8de4b --- /dev/null +++ b/scripts/deploy-web.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# ============================================================================= +# Cut nginx over to serve the PYRE app at / (and the dev tracker at /status). +# ============================================================================= +# Prereqs: the app is built + running under pm2 (pyre-web:3000, pyre-api:4000), +# and Phase 0 provisioning already obtained a TLS cert for feedthepyre.com. +# +# Run as root: sudo bash scripts/deploy-web.sh +# Idempotent + re-runnable. +# ============================================================================= +set -euo pipefail + +DOMAIN="feedthepyre.com" +WWW_DOMAIN="www.feedthepyre.com" +REPO_DIR="/home/pyre/pyre" +CERTBOT_EMAIL="${CERTBOT_EMAIL:-a31s15.roguewave@gmail.com}" +VHOST_SRC="${REPO_DIR}/infra/nginx/${DOMAIN}.conf" +VHOST_AVAIL="/etc/nginx/sites-available/${DOMAIN}" +VHOST_ENABLED="/etc/nginx/sites-enabled/${DOMAIN}" + +if [[ "${EUID}" -ne 0 ]]; then + echo "Must run as root: sudo bash ${0}" >&2 + exit 1 +fi + +echo "==> Installing nginx vhost (app at / , tracker at /status , api at /api)" +install -m 0644 "${VHOST_SRC}" "${VHOST_AVAIL}" +ln -sfn "${VHOST_AVAIL}" "${VHOST_ENABLED}" + +echo "==> nginx -t" +nginx -t +systemctl reload nginx + +echo "==> Re-applying TLS (certbot mirrors the server to a 443 block; idempotent)" +certbot --nginx -d "${DOMAIN}" -d "${WWW_DOMAIN}" \ + --non-interactive --agree-tos -m "${CERTBOT_EMAIL}" \ + --redirect --keep-until-expiring || { + echo "[WARN] certbot did not complete; HTTP is live, re-run once DNS/cert is ready." >&2 + } +systemctl reload nginx + +echo "Done." +echo " https://${DOMAIN}/ -> PYRE app (pm2 pyre-web:3000)" +echo " https://${DOMAIN}/api/scan -> API (pm2 pyre-api:4000)" +echo " https://${DOMAIN}/status/ -> dev status tracker (static)"