feat(web+infra): polished front page, app at /, tracker at /status
- apps/web: redesigned landing (Hero/Scanner/HowItWorks/Features/Footer), honest live-vs-coming-soon badges, same-origin /api/scan, ember theme. - ecosystem.config.cjs: runnable — pyre-api/worker via `node --import tsx`, pyre-web via `next start`, fork mode, env wired. pm2 web+api verified online (api /health 200, scan 200, web 200). - infra/nginx/feedthepyre.com.conf: app at / (proxy :3000), API at /api (proxy :4000, prefix preserved), dev tracker at /status (static). - scripts/deploy-web.sh: sudo cutover (install vhost, nginx -t, reload, certbot --nginx --keep-until-expiring). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,37 +25,305 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
max-width: 56rem;
|
max-width: 64rem;
|
||||||
margin: 0 auto;
|
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 / header */
|
||||||
.hero {
|
.hero {
|
||||||
|
position: relative;
|
||||||
text-align: center;
|
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 {
|
.hero__title {
|
||||||
font-size: clamp(3rem, 10vw, 5rem);
|
font-size: clamp(3.5rem, 13vw, 6.5rem);
|
||||||
font-weight: 800;
|
font-weight: 900;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
|
line-height: 1;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: linear-gradient(180deg, var(--color-ember-bright), var(--color-ember));
|
background: linear-gradient(180deg, var(--color-ember-bright), var(--color-ember));
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
color: transparent;
|
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 {
|
.hero__tagline {
|
||||||
font-size: 1.15rem;
|
font-size: clamp(1.1rem, 3.5vw, 1.45rem);
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: var(--color-ember-bright);
|
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 {
|
.hero__trust {
|
||||||
color: var(--color-smoke);
|
color: var(--color-smoke);
|
||||||
|
font-style: italic;
|
||||||
margin: 0;
|
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 / scan controls */
|
||||||
.connect {
|
.connect {
|
||||||
@@ -64,7 +332,6 @@ body {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scan-btn {
|
.scan-btn {
|
||||||
|
|||||||
@@ -1,204 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { useWallet } from "@solana/wallet-adapter-react";
|
import { useWallet } from "@solana/wallet-adapter-react";
|
||||||
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
|
import { Hero } from "../components/Hero";
|
||||||
import { TokenClassification } from "@pyre/core";
|
import { Scanner } from "../components/Scanner";
|
||||||
import type { ScanResponse, TokenAccountDto } from "@pyre/core";
|
import { HowItWorks } from "../components/HowItWorks";
|
||||||
|
import { Features } from "../components/Features";
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
import { Footer } from "../components/Footer";
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<li className="account-row">
|
|
||||||
<div className="account-row__main">
|
|
||||||
<span className="account-row__label" title={account.mint}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span className="account-row__mint" title={account.mint}>
|
|
||||||
{truncate(account.mint)}
|
|
||||||
</span>
|
|
||||||
<span className="account-row__balance">{account.uiBalance}</span>
|
|
||||||
</div>
|
|
||||||
{account.warnings.length > 0 && (
|
|
||||||
<ul className="account-row__warnings">
|
|
||||||
{account.warnings.map((w, i) => (
|
|
||||||
<li key={i}>⚠ {w}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { publicKey } = useWallet();
|
const { connected } = useWallet();
|
||||||
const [scan, setScan] = useState<ScanResponse | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(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<TokenClassification, TokenAccountDto[]>();
|
|
||||||
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 (
|
return (
|
||||||
<main className="page">
|
<main className="page">
|
||||||
<header className="hero">
|
<Hero connected={connected} />
|
||||||
<h1 className="hero__title">PYRE</h1>
|
<Scanner />
|
||||||
<p className="hero__tagline">
|
<HowItWorks />
|
||||||
Burn the dead. Feed the PYRE. Claim the Spawn.
|
<Features />
|
||||||
</p>
|
<Footer />
|
||||||
<p className="hero__trust">
|
|
||||||
PYRE returns your rent. The scraps feed the fire.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="connect">
|
|
||||||
<WalletMultiButton />
|
|
||||||
{wallet && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="scan-btn"
|
|
||||||
onClick={runScan}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? "Scanning…" : "Scan wallet"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{!wallet && (
|
|
||||||
<p className="hint">Connect a wallet to scan it for reclaimable rent.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <p className="error">Something went wrong: {error}</p>}
|
|
||||||
|
|
||||||
{scan && (
|
|
||||||
<section className="results">
|
|
||||||
<div className="reclaim">
|
|
||||||
<p className="reclaim__headline">
|
|
||||||
Recover ~{reclaimSol.toFixed(6)} SOL from{" "}
|
|
||||||
{scan.summary.emptyCloseOnly} empty account
|
|
||||||
{scan.summary.emptyCloseOnly === 1 ? "" : "s"}.
|
|
||||||
</p>
|
|
||||||
<p className="reclaim__sub">
|
|
||||||
Scanned {scan.summary.totalAccounts} token account
|
|
||||||
{scan.summary.totalAccounts === 1 ? "" : "s"}.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{SECTIONS.map(({ classification, heading, blurb }) => {
|
|
||||||
const accounts = grouped.get(classification) ?? [];
|
|
||||||
if (accounts.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div key={classification} className="result-section">
|
|
||||||
<h2 className="result-section__heading">
|
|
||||||
{heading}{" "}
|
|
||||||
<span className="result-section__count">
|
|
||||||
({accounts.length})
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
<p className="result-section__blurb">{blurb}</p>
|
|
||||||
<ul className="account-list">
|
|
||||||
{accounts.map((a) => (
|
|
||||||
<AccountRow key={a.ata} account={a} />
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<p className="preview-note">
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
68
apps/web/src/components/Features.tsx
Normal file
68
apps/web/src/components/Features.tsx
Normal file
@@ -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 (
|
||||||
|
<section className="features" aria-labelledby="features-heading">
|
||||||
|
<h2 className="section-heading" id="features-heading">
|
||||||
|
What's live, what's coming
|
||||||
|
</h2>
|
||||||
|
<div className="features__grid">
|
||||||
|
{FEATURES.map(({ title, body, status, statusLabel }) => (
|
||||||
|
<article
|
||||||
|
key={title}
|
||||||
|
className={`feature-card feature-card--${status}`}
|
||||||
|
>
|
||||||
|
<div className="feature-card__top">
|
||||||
|
<h3 className="feature-card__title">{title}</h3>
|
||||||
|
<span className={badgeClass(status)}>{statusLabel}</span>
|
||||||
|
</div>
|
||||||
|
<p className="feature-card__body">{body}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/web/src/components/Footer.tsx
Normal file
33
apps/web/src/components/Footer.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Footer: dev status tracker (served separately by nginx as a static /status
|
||||||
|
* page — plain <a>, 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 (
|
||||||
|
<footer className="footer">
|
||||||
|
<div className="footer__brand">
|
||||||
|
<span className="footer__wordmark">PYRE</span>
|
||||||
|
<span className="footer__motto">
|
||||||
|
Burn the dead. Feed the PYRE. Claim the Spawn.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="footer__links" aria-label="Footer">
|
||||||
|
<a href="/status">Dev status</a>
|
||||||
|
<a href={REPO_URL} target="_blank" rel="noreferrer noopener">
|
||||||
|
Repository
|
||||||
|
</a>
|
||||||
|
<a href="#scanner">Scanner</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<p className="footer__disclaimer">
|
||||||
|
PYRE is not an investment product, yield mechanism, trading bot, or
|
||||||
|
guaranteed-profit system. It is wallet-cleanup utility and ritual
|
||||||
|
entertainment. Recovered rent returns to you.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/web/src/components/Hero.tsx
Normal file
38
apps/web/src/components/Hero.tsx
Normal file
@@ -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 (
|
||||||
|
<header className="hero">
|
||||||
|
<div className="hero__glow" aria-hidden="true" />
|
||||||
|
<p className="hero__eyebrow">Solana wallet cleanup ritual</p>
|
||||||
|
<h1 className="hero__title">PYRE</h1>
|
||||||
|
<p className="hero__tagline">
|
||||||
|
Burn the dead. Feed the PYRE. Claim the Spawn.
|
||||||
|
</p>
|
||||||
|
<p className="hero__value">
|
||||||
|
Clean dead Solana token accounts and reclaim the SOL rent trapped in
|
||||||
|
them.
|
||||||
|
</p>
|
||||||
|
<p className="hero__trust">
|
||||||
|
PYRE returns your rent. The scraps feed the fire.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="hero__cta">
|
||||||
|
<WalletMultiButton />
|
||||||
|
<a className="hero__secondary" href="#scanner">
|
||||||
|
{connected ? "Go to scanner" : "See how it works"}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="hero__note">
|
||||||
|
Read-only scan. Nothing is signed — you stay in control of your wallet.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
apps/web/src/components/HowItWorks.tsx
Normal file
55
apps/web/src/components/HowItWorks.tsx
Normal file
@@ -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 (
|
||||||
|
<section className="how" id="how-it-works" aria-labelledby="how-heading">
|
||||||
|
<h2 className="section-heading" id="how-heading">
|
||||||
|
How it works
|
||||||
|
</h2>
|
||||||
|
<ol className="how__grid">
|
||||||
|
{STEPS.map(({ step, title, body, soon }) => (
|
||||||
|
<li key={step} className="how__step">
|
||||||
|
<span className="how__num">{step}</span>
|
||||||
|
<h3 className="how__title">
|
||||||
|
{title}
|
||||||
|
{soon && <span className="badge badge--soon">Coming soon</span>}
|
||||||
|
</h3>
|
||||||
|
<p className="how__body">{body}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
apps/web/src/components/Scanner.tsx
Normal file
221
apps/web/src/components/Scanner.tsx
Normal file
@@ -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 (
|
||||||
|
<li className="account-row">
|
||||||
|
<div className="account-row__main">
|
||||||
|
<span className="account-row__label" title={account.mint}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="account-row__mint" title={account.mint}>
|
||||||
|
{truncate(account.mint)}
|
||||||
|
</span>
|
||||||
|
<span className="account-row__balance">{account.uiBalance}</span>
|
||||||
|
</div>
|
||||||
|
{account.warnings.length > 0 && (
|
||||||
|
<ul className="account-row__warnings">
|
||||||
|
{account.warnings.map((w, i) => (
|
||||||
|
<li key={i}>⚠ {w}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Scanner() {
|
||||||
|
const { publicKey } = useWallet();
|
||||||
|
const [scan, setScan] = useState<ScanResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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<TokenClassification, TokenAccountDto[]>();
|
||||||
|
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 (
|
||||||
|
<section className="scanner" id="scanner" aria-labelledby="scanner-heading">
|
||||||
|
<div className="scanner__head">
|
||||||
|
<h2 className="section-heading" id="scanner-heading">
|
||||||
|
Live scanner
|
||||||
|
</h2>
|
||||||
|
<p className="scanner__sub">
|
||||||
|
Connect a wallet and run a read-only scan for reclaimable rent.
|
||||||
|
Nothing is signed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scanner__panel">
|
||||||
|
<div className="connect">
|
||||||
|
<WalletMultiButton />
|
||||||
|
{wallet && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="scan-btn"
|
||||||
|
onClick={runScan}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Scanning…" : "Scan wallet"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!wallet && (
|
||||||
|
<p className="hint">
|
||||||
|
Connect a wallet above to scan it for reclaimable rent.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="scanner__loading" role="status" aria-live="polite">
|
||||||
|
<span className="scanner__spinner" aria-hidden="true" />
|
||||||
|
Reading token accounts…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="error" role="alert">
|
||||||
|
Something went wrong: {error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scan && (
|
||||||
|
<div className="results">
|
||||||
|
<div className="reclaim">
|
||||||
|
<p className="reclaim__headline">
|
||||||
|
Recover ~{reclaimSol.toFixed(6)} SOL from{" "}
|
||||||
|
{scan.summary.emptyCloseOnly} empty account
|
||||||
|
{scan.summary.emptyCloseOnly === 1 ? "" : "s"}.
|
||||||
|
</p>
|
||||||
|
<p className="reclaim__sub">
|
||||||
|
Scanned {scan.summary.totalAccounts} token account
|
||||||
|
{scan.summary.totalAccounts === 1 ? "" : "s"}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{SECTIONS.map(({ classification, heading, blurb }) => {
|
||||||
|
const accounts = grouped.get(classification) ?? [];
|
||||||
|
if (accounts.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={classification} className="result-section">
|
||||||
|
<h3 className="result-section__heading">
|
||||||
|
{heading}{" "}
|
||||||
|
<span className="result-section__count">
|
||||||
|
({accounts.length})
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<p className="result-section__blurb">{blurb}</p>
|
||||||
|
<ul className="account-list">
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<AccountRow key={a.ata} account={a} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<p className="preview-note">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,74 +1,82 @@
|
|||||||
// PYRE / Prometheus Protocol — PM2 ecosystem (process manager) config
|
// 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.
|
|
||||||
//
|
//
|
||||||
// Process names match docs/PYRE_MVP_DESIGN.md §12: pyre-web, pyre-api, pyre-worker.
|
// Process names match docs/PYRE_MVP_DESIGN.md §12: pyre-web, pyre-api, pyre-worker.
|
||||||
//
|
// Start (from repo root): pm2 start ecosystem.config.cjs --only pyre-api,pyre-web
|
||||||
// Once apps exist, start with:
|
// Persist for boot: pm2 save (systemd unit infra/systemd/pm2-pyre.service
|
||||||
// pm2 start ecosystem.config.cjs
|
// runs `pm2 resurrect` on boot)
|
||||||
// pm2 save # persist process list so `pm2 resurrect` works on boot
|
|
||||||
//
|
//
|
||||||
// PM2 is installed at user level: ~/.local/share/pnpm/bin/pm2
|
// PM2 is installed at user level: ~/.local/share/pnpm/bin/pm2
|
||||||
// Logs go to /home/pyre/pyre/logs/ (rotated by infra/logrotate/pyre).
|
// 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.,
|
// NOTE: api/worker run their TypeScript directly via `node --import tsx` (no
|
||||||
// so each process is capped at 400M via max_memory_restart.
|
// 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 = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
// Next.js frontend (production) — port 3000 per .env.example (WEB_PORT).
|
// Next.js frontend (production) — port 3000 per .env.example (WEB_PORT).
|
||||||
|
// Requires `pnpm --filter @pyre/web build` first.
|
||||||
name: "pyre-web",
|
name: "pyre-web",
|
||||||
cwd: "apps/web",
|
cwd: `${REPO}/apps/web`,
|
||||||
script: "pnpm",
|
script: "node_modules/next/dist/bin/next",
|
||||||
args: "start", // runs `next start` (requires a prior `pnpm build`)
|
args: "start",
|
||||||
instances: 1,
|
instances: 1,
|
||||||
|
exec_mode: "fork",
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
max_memory_restart: "400M",
|
max_memory_restart: "500M",
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
PORT: 3000,
|
PORT: 3000,
|
||||||
|
HOSTNAME: "127.0.0.1",
|
||||||
},
|
},
|
||||||
out_file: "/home/pyre/pyre/logs/pyre-web-out.log",
|
out_file: `${REPO}/logs/pyre-web-out.log`,
|
||||||
error_file: "/home/pyre/pyre/logs/pyre-web-err.log",
|
error_file: `${REPO}/logs/pyre-web-err.log`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Fastify HTTP API — port 4000 per .env.example (API_PORT).
|
// Fastify HTTP API — port 4000 per .env.example (API_PORT).
|
||||||
// Runs the compiled server. Until a build exists you can temporarily
|
// Runs TS directly via tsx (resolves workspace @pyre/* source).
|
||||||
// swap to a dev runner: script: "pnpm", args: "dev"
|
|
||||||
name: "pyre-api",
|
name: "pyre-api",
|
||||||
cwd: "apps/api",
|
cwd: `${REPO}/apps/api`,
|
||||||
script: "node",
|
script: "src/index.ts",
|
||||||
args: "dist/index.js",
|
interpreter: "node",
|
||||||
|
interpreter_args: "--import tsx",
|
||||||
instances: 1,
|
instances: 1,
|
||||||
|
exec_mode: "fork",
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
max_memory_restart: "400M",
|
max_memory_restart: "400M",
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
PORT: 4000,
|
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",
|
out_file: `${REPO}/logs/pyre-api-out.log`,
|
||||||
error_file: "/home/pyre/pyre/logs/pyre-api-err.log",
|
error_file: `${REPO}/logs/pyre-api-err.log`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// BullMQ background worker (no HTTP port).
|
// BullMQ background worker (no HTTP port). Not started by default in
|
||||||
// Runs the compiled worker. Until a build exists you can temporarily
|
// Phase 1 (no jobs implemented yet). Runs TS via tsx when enabled.
|
||||||
// swap to a dev runner: script: "pnpm", args: "dev"
|
|
||||||
name: "pyre-worker",
|
name: "pyre-worker",
|
||||||
cwd: "apps/worker",
|
cwd: `${REPO}/apps/worker`,
|
||||||
script: "node",
|
script: "src/index.ts",
|
||||||
args: "dist/index.js",
|
interpreter: "node",
|
||||||
|
interpreter_args: "--import tsx",
|
||||||
instances: 1,
|
instances: 1,
|
||||||
|
exec_mode: "fork",
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
max_memory_restart: "400M",
|
max_memory_restart: "400M",
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
},
|
},
|
||||||
out_file: "/home/pyre/pyre/logs/pyre-worker-out.log",
|
out_file: `${REPO}/logs/pyre-worker-out.log`,
|
||||||
error_file: "/home/pyre/pyre/logs/pyre-worker-err.log",
|
error_file: `${REPO}/logs/pyre-worker-err.log`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,109 +1,63 @@
|
|||||||
# ============================================================================
|
# =============================================================================
|
||||||
# PYRE / Prometheus Protocol — nginx virtual host for feedthepyre.com
|
# nginx vhost — feedthepyre.com
|
||||||
# ----------------------------------------------------------------------------
|
# =============================================================================
|
||||||
# Install path: /etc/nginx/sites-available/feedthepyre.com
|
# Serves the PYRE app at /, the API at /api, and the dev status tracker at
|
||||||
# (the provision script symlinks this into sites-enabled/)
|
# /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
|
# Upstreams (pm2): web = 127.0.0.1:3000 (Next.js), api = 127.0.0.1:4000 (Fastify)
|
||||||
# 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.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name feedthepyre.com www.feedthepyre.com;
|
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;
|
access_log /var/log/nginx/feedthepyre.access.log;
|
||||||
error_log /var/log/nginx/feedthepyre.error.log;
|
error_log /var/log/nginx/feedthepyre.error.log;
|
||||||
|
|
||||||
# --- ACME HTTP-01 challenge --------------------------------------------
|
gzip on;
|
||||||
# Explicit so certbot's HTTP-01 validation works even before its --nginx
|
gzip_proxied any;
|
||||||
# tweaks are applied. ^~ ensures this wins over the regex/proxy locations.
|
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/ {
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
root /var/www/feedthepyre/status;
|
root /var/www/feedthepyre/status;
|
||||||
allow all;
|
allow all;
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Basic hardening ----------------------------------------------------
|
# --- Dev status tracker (static) -> /status -----------------------------
|
||||||
# gzip for text-ish content types.
|
location = /status { return 301 /status/; }
|
||||||
gzip on;
|
location /status/ {
|
||||||
gzip_comp_level 5;
|
alias /var/www/feedthepyre/status/;
|
||||||
gzip_min_length 256;
|
index index.html;
|
||||||
gzip_proxied any;
|
try_files $uri $uri/ /status/index.html;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------------
|
# --- API (Fastify) ------------------------------------------------------
|
||||||
# REVERSE-PROXY BLOCKS — enable when apps are running
|
# No trailing slash on proxy_pass: the /api/ prefix is preserved, so the
|
||||||
# ------------------------------------------------------------------------
|
# backend receives /api/scan (its actual route).
|
||||||
# Uncomment the /api/ block below once apps/api (Fastify, port 4000) is up.
|
location /api/ {
|
||||||
# The trailing slash on proxy_pass strips the /api/ prefix so the backend
|
proxy_pass http://127.0.0.1:4000;
|
||||||
# sees /scan, /receipt, etc.
|
proxy_http_version 1.1;
|
||||||
#
|
proxy_set_header Host $host;
|
||||||
# location /api/ {
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
# proxy_pass http://127.0.0.1:4000/;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
# proxy_http_version 1.1;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
# proxy_set_header Host $host;
|
proxy_read_timeout 60s;
|
||||||
# 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;
|
# --- Web app (Next.js) --------------------------------------------------
|
||||||
# proxy_set_header Upgrade $http_upgrade;
|
location / {
|
||||||
# proxy_set_header Connection $connection_upgrade;
|
proxy_pass http://127.0.0.1:3000;
|
||||||
# }
|
proxy_http_version 1.1;
|
||||||
#
|
proxy_set_header Host $host;
|
||||||
# The websocket Upgrade/Connection headers above rely on a $connection_upgrade
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
# map. Add this once in the http{} block of /etc/nginx/nginx.conf:
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
#
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
# map $http_upgrade $connection_upgrade {
|
}
|
||||||
# default upgrade;
|
|
||||||
# '' close;
|
|
||||||
# }
|
|
||||||
# ------------------------------------------------------------------------
|
|
||||||
}
|
}
|
||||||
|
|||||||
45
scripts/deploy-web.sh
Executable file
45
scripts/deploy-web.sh
Executable file
@@ -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)"
|
||||||
Reference in New Issue
Block a user