docs+web: refresh CLAUDE.md/README to current state; operator /admin console

- CLAUDE.md: replaced stale "scaffold only" with an accurate Built / In progress /
  Not-built status; added a Secrets section; updated dev commands + pm2/.env notes.
- README.md: roadmap reflects v0.1–v0.4 working (clean→burn→fee→Essence→Prometheus),
  sell=detection-only, v1.0 pending; quick-start + secrets accurate.
- apps/web /admin: operator console — paste admin token (sessionStorage, never
  baked/committed), generate Spawns (chaos/seed/receiptId) + record manual Pump.fun
  launches; 403 re-prompts. Public route, gated by the API token.

web build green (+/admin). Status site already redeployed (Phase 4 updated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 07:27:44 +00:00
parent 6ab0f02d06
commit 6dd541b9f4
5 changed files with 1082 additions and 70 deletions

112
CLAUDE.md
View File

@@ -40,35 +40,54 @@ These are load-bearing. Do not weaken, work around, or "optimize" them. See
--- ---
## Current status & scope guardrail ## Current status — Built / In progress / Not yet built
**MVP v0.1 is a burner/cleaner only.** This repo is currently **scaffold + docs**: This repo is **no longer scaffold + docs** — the burner core is implemented and
**Solana transaction logic and business logic are NOT yet implemented.** Do not live at [feedthepyre.com](https://feedthepyre.com). Keep new work aligned with the
add application/business logic unless explicitly asked. design doc; the trust rules above are non-negotiable.
**Explicitly OUT of scope for v0.1** (per §5): **Built (working):**
- Automatic Pump.fun launch - **Wallet scan + conservative classifier** (`@pyre/core`): EMPTY_CLOSE_ONLY /
- User-contributed Essence vault INCINERATE_ONLY / TRANSMUTABLE / PROTECTED_SKIP / UNSUPPORTED. "Unknown means
- Custom PYRE Solana program (Anchor) skip"; never "safe".
- NFT handling (incl. compressed NFTs) - **Token-2022** supported conservatively with account+mint **extension gating**
- Automatic valuable-token sacrifice (§7.1): confidential transfer / withheld transfer fees / frozen / unknown
- Custodial signing extensions skipped; transfer-hook & permanent-delegate mints cleanable but
- Background wallet automation flagged; unverifiable mints → UNSUPPORTED. In `@pyre/core` (`extensions.ts`,
- On-chain swap routing (TRANSMUTABLE) and Token-2022 confidential-transfer / `classify.ts`) + `@pyre/solana`.
fee-harvest flows - **Close-empty + burn→close transactions** (`@pyre/solana`): UNSIGNED, server
re-validates on-chain, value-gated, rent → user, with a transparent **5%
protocol fee** (§3.1) to the treasury. Decoded + matched in the web app before
signing. Live-verified on mainnet.
- **Sell / transmute DETECTION** via Jupiter (read-only quotes + Shield + dust
gate) in `@pyre/core` (`sell.ts`) and the API (`jupiter.ts`). Swap **execution
is not built**.
- **Essence ledger** (`@pyre/db`, Postgres: `rounds`, `cleanup_receipts`,
`essence_contributions`): `/api/receipt` persists the receipt and records the
on-chain fee as Essence; public "🔥 fed the PYRE" panel + `/api/essence`.
- **Prometheus engine** (`@pyre/prometheus`): meta-mixer + name/ticker/lore/
tagline + image-prompt + safety. Provider-abstracted with real providers
(Gemini/Anthropic/OpenAI text; Pollinations/fal/DeepInfra/Replicate image;
OpenAI moderation) plus a deterministic stub fallback; runs on a free stack
today.
- **Pump.fun creator workflow** (manual / approval-gated): `spawn_records`,
admin-gated generate/launch endpoints, public `/spawn` page.
**Token-2022 IS in v0.1 scope** (Rev 2 — most new/pump.fun tokens are Token-2022), **In progress:**
supported conservatively with account+mint **extension gating**: confidential
transfer / withheld transfer fees / frozen / unknown extensions are skipped;
transfer-hook & permanent-delegate mints are cleanable but flagged. See
[`docs/PYRE_MVP_DESIGN.md`](docs/PYRE_MVP_DESIGN.md) §7.1. Implemented in
`@pyre/core` (`extensions.ts` + `classify.ts`) and `@pyre/solana` (account+mint
extension reads); unverifiable mints → UNSUPPORTED.
v0.1 ships: wallet connect → scan token accounts (classic SPL + Token-2022) → - Admin generation UI (review/approve Spawn packages).
classify → close eligible empty ATAs (optionally burn obvious junk) → return rent - Generation-from-receipt wiring (`/api/prometheus/generate` already pulls
to user → show receipt. receipt context; full receipt → Spawn flow being finished).
**Not yet built:**
- Swap **execution** (TRANSMUTABLE detection only today).
- On-chain claim program v1.0 (`programs/pyre-core` is still a placeholder).
- Background worker jobs (`apps/worker` is a skeleton — no queues wired up).
Token-2022 close/burn is in scope; confidential-transfer / fee-harvest flows and
swapping hook/permanent-delegate tokens remain out of scope.
--- ---
@@ -81,8 +100,8 @@ pyre/
# cleanup preview, receipt page, Prometheus preview, admin review # cleanup preview, receipt page, Prometheus preview, admin review
api/ # Fastify HTTP API: scan, classify, build tx, receipt, api/ # Fastify HTTP API: scan, classify, build tx, receipt,
# generation, admin endpoints # generation, admin endpoints
worker/ # BullMQ worker: async metadata lookup, AI generation, worker/ # BullMQ worker (SKELETON — jobs not yet wired): planned async
# safety checks, tx-confirmation watcher, receipt enrichment # metadata lookup, AI generation, safety, tx-confirm, enrichment
packages/ packages/
core/ # shared types & logic: classification enums, risk rules, core/ # shared types & logic: classification enums, risk rules,
# DTOs, receipt schema, Prometheus I/O schema # DTOs, receipt schema, Prometheus I/O schema
@@ -127,12 +146,14 @@ Node 22 is required.
pnpm install # install workspace deps pnpm install # install workspace deps
pnpm -r build # build all packages/apps pnpm -r build # build all packages/apps
pnpm -r typecheck # type-check all pnpm -r typecheck # type-check all
pnpm -r test # run all tests pnpm -r test # run all test suites (real tests in core/solana/prometheus)
pnpm dev # run apps in parallel (dev) pnpm dev # run apps in parallel (dev)
``` ```
> **Nothing is installed yet.** These commands are not runnable until the Production runs under **pm2** (`ecosystem.config.cjs`: `pyre-web`, `pyre-api`,
> skeleton's `package.json` files and dependencies exist. `pyre-worker`). The API and worker are launched with
`node --env-file-if-exists=<repo>/.env --import tsx`, so the API loads secrets
from the gitignored `.env` at the repo root.
--- ---
@@ -145,6 +166,17 @@ pnpm dev # run apps in parallel (dev)
--- ---
## Secrets
Secrets live in a **gitignored `.env` at the repo root** (chmod 600), loaded via
`node --env-file-if-exists`. **Never commit keys** — there is no private-key env
var by design (§trust rules), and `.env.example` documents the shape only. Admin
endpoints (`/api/prometheus/generate`, Spawn launch) require an `x-admin-token`
header equal to `ADMIN_API_TOKEN`; when that token is unset, admin endpoints are
**closed** (403), not open.
---
## Docs ## Docs
- [`docs/PYRE_MVP_DESIGN.md`](docs/PYRE_MVP_DESIGN.md) — canonical design (source of truth) - [`docs/PYRE_MVP_DESIGN.md`](docs/PYRE_MVP_DESIGN.md) — canonical design (source of truth)
@@ -156,19 +188,11 @@ pnpm dev # run apps in parallel (dev)
--- ---
## Bootstrap prompts (§20) ## Working in this repo
Use these in order. **Plan first — no code:** The skeleton and burner core already exist (see "Current status" above). When
extending, stay inside the design doc's scope and the trust rules. Before
> Read CLAUDE.md and docs/PYRE_MVP_DESIGN.md. Do not write code yet. Produce an touching transaction-building or classification code, read the relevant section
> implementation plan for PYRE MVP v0.1 focused only on wallet scanning, token of [`docs/PYRE_MVP_DESIGN.md`](docs/PYRE_MVP_DESIGN.md) (§6, §7, §8, §16) — that
> account classification, close-empty-ATA transaction building, transaction document wins on any conflict. Run `pnpm -r typecheck` and `pnpm -r test` after
> preview, and receipt generation. Identify the exact packages, APIs, database changes.
> tables, and test cases needed.
Then, after plan review — **skeleton only:**
> Create the monorepo skeleton with pnpm workspaces. Add apps/web, apps/api,
> apps/worker, packages/core, packages/solana, packages/prometheus, packages/db,
> and docs. Add TypeScript configs, package.json files, README files, and
> .env.example files. Do not implement Solana transaction logic yet.

View File

@@ -5,11 +5,15 @@
**Links:** [feedthepyre.com](https://feedthepyre.com) · repo: `git.lumiai.dev/RogueWave/pyre` · dev status: [feedthepyre.com](https://feedthepyre.com) (status dashboard) **Links:** [feedthepyre.com](https://feedthepyre.com) · repo: `git.lumiai.dev/RogueWave/pyre` · dev status: [feedthepyre.com](https://feedthepyre.com) (status dashboard)
PYRE is a **Solana wallet-cleanup and ritual meme-rebirth protocol**. You connect PYRE is a **Solana wallet-cleanup and ritual meme-rebirth protocol**. You connect
a wallet; PYRE scans your SPL token accounts, classifies them conservatively, and a wallet; PYRE scans your SPL token accounts (classic **and** Token-2022),
helps you safely close empty associated token accounts (ATAs) and burn obvious classifies them conservatively, and helps you safely close empty associated token
junk — **returning recovered rent to you** and producing a clear, shareable accounts (ATAs) and burn obvious junk — **returning recovered rent to you** (minus
receipt. A later layer (Prometheus) uses AI to generate a meme-token "Spawn" from a transparent 5% protocol fee) and producing a clear, shareable receipt. The
burned remnants for **manual, human-reviewed** launch. Prometheus engine uses AI to generate a meme-token "Spawn" from burned remnants
for **manual, human-reviewed** launch on Pump.fun.
The burner core is **live at [feedthepyre.com](https://feedthepyre.com)** and the
5%-fee close/burn flow has been verified end-to-end on mainnet.
The first emotional win is simple: *"PYRE cleaned my wallet and returned SOL I The first emotional win is simple: *"PYRE cleaned my wallet and returned SOL I
forgot was trapped in token accounts."* forgot was trapped in token accounts."*
@@ -26,8 +30,10 @@ promises.
**Trust guarantees:** PYRE never holds your private keys, never signs **Trust guarantees:** PYRE never holds your private keys, never signs
custodially, and always shows a decoded transaction preview that matches what you custodially, and always shows a decoded transaction preview that matches what you
sign. Recovered rent goes back to your wallet by default. Anything the system sign. Recovered rent goes back to your wallet, minus a single **transparent 5%
cannot safely reason about is skipped. protocol fee** shown before you sign (it funds the Spawn — see the roadmap).
Swap proceeds always go to you. Anything the system cannot safely reason about is
skipped.
## The burner flow at a glance ## The burner flow at a glance
@@ -35,25 +41,34 @@ cannot safely reason about is skipped.
Connect wallet Connect wallet
→ scan token accounts → scan token accounts
→ classify accounts (closeable / burnable / transmutable / protected / unsupported) → classify accounts (closeable / burnable / transmutable / protected / unsupported)
→ preview the transaction (accounts, rent, destination, fees, warnings) → preview the transaction (accounts, rent, destination, 5% fee, warnings)
→ you sign locally in your wallet → you sign locally in your wallet
→ recovered rent returns to you → recovered rent (minus the fee) returns to you; the fee feeds the PYRE
→ see your PYRE receipt → see your PYRE receipt
``` ```
## Roadmap ## Roadmap & status
- **v0.1 — Burner / Cleaner** *(current focus)*: wallet connect, scan, classify, - **v0.1 — Burner / Cleaner** **working.** Wallet connect, scan, conservative
close empty ATAs, optional junk burn, rent return, receipt. classification (classic SPL + Token-2022 with extension gating), close empty
- **v0.2 — Prometheus Meta Mixer**: AI generation of a Spawn identity from ATAs, burn-then-close junk, rent return minus the transparent 5% fee, receipt.
burned/cleaned token context (candidate package only — no auto-launch). Live on mainnet.
- **v0.3Manual Pump.fun Workflow**: human reviews the Spawn package and - **v0.2Prometheus Meta Mixer** — **working.** AI generation of a Spawn
manually creates the token; PYRE records mint, URL, metadata, and tx. identity (name/ticker/lore/tagline/image-prompt + safety) from burned/cleaned
- **v0.4 — Essence Ledger**: record net SOL value of safe scrap swaps as Essence token context; provider-abstracted with a deterministic stub fallback. Produces
per wallet/round (database-only, experimental, no claim promises). a candidate package only — no auto-launch. *In progress:* admin review UI and
- **v1.0 — PYRE Core Program**: custom Solana (Anchor) program for trust-critical finishing the receipt → generation wiring.
accounting — rounds, Essence vault, contribution receipts, Spawn distribution, - **v0.3 — Manual Pump.fun Workflow** — **working (manual, approval-gated).**
claims, and refunds. Admin-gated generate/launch endpoints record mint, URL, metadata, and tx; public
`/spawn` page. Human creates the token.
- **v0.4 — Essence Ledger** — **working (database-only).** The on-chain 5% fee is
recorded as Essence per round in Postgres; a public "🔥 fed the PYRE" panel and
`/api/essence` show round progress. Experimental, no claim promises.
- **Sell / Transmute** — **detection only.** Jupiter quotes + Shield + dust gate
classify TRANSMUTABLE tokens; swap **execution is not built yet**.
- **v1.0 — PYRE Core Program** — **not built.** Future Solana (Anchor) program for
trust-critical accounting: rounds, Essence vault, contribution receipts, Spawn
distribution, claims, refunds.
## Repo structure ## Repo structure
@@ -61,7 +76,7 @@ Connect wallet
apps/ apps/
web/ Next.js user app (wallet connect, scanner, preview, receipt) web/ Next.js user app (wallet connect, scanner, preview, receipt)
api/ Fastify HTTP API (scan, classify, build tx, receipt, generation) api/ Fastify HTTP API (scan, classify, build tx, receipt, generation)
worker/ BullMQ background worker (metadata, AI, safety, confirmations) worker/ BullMQ background worker (skeleton — jobs not yet wired)
packages/ packages/
core/ shared types, classification enums, risk rules, schemas core/ shared types, classification enums, risk rules, schemas
solana/ token-account parsing, close/burn tx builders, decoder, simulation solana/ token-account parsing, close/burn tx builders, decoder, simulation
@@ -75,17 +90,20 @@ docs/ design, architecture, security, classification
## Quick start ## Quick start
> **This is a scaffold.** The commands below are not yet runnable — the workspace
> package definitions and source are still being built out.
**Prerequisites:** Node 22, pnpm, PostgreSQL, Redis. **Prerequisites:** Node 22, pnpm, PostgreSQL, Redis.
```bash ```bash
cp .env.example .env # then fill in values (no private keys — by design) cp .env.example .env # then fill in values (no private keys — by design)
pnpm install pnpm install
pnpm dev # (once apps are implemented) pnpm -r build # build all packages/apps
pnpm -r test # run the test suites
pnpm dev # run web + api in dev
``` ```
Secrets are read from the gitignored `.env` at the repo root; never commit keys.
Admin endpoints require an `ADMIN_API_TOKEN` (see `.env.example`). In production
the apps run under pm2 (`ecosystem.config.cjs`).
## Docs ## Docs
See [`docs/`](docs/) — start with See [`docs/`](docs/) — start with

View File

@@ -0,0 +1,630 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { PrometheusGenerateResponse, SpawnRecord } from "@pyre/core";
import { Footer } from "../../components/Footer";
// Same-origin by default so production hits "/api/…" 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 ?? "";
// The admin token is operator-supplied at runtime and kept ONLY in sessionStorage
// (cleared when the tab closes). It is NEVER read from an env var, NEVER baked
// into the build, and NEVER committed. It travels only as the `x-admin-token`
// header on admin calls.
const TOKEN_KEY = "pyre.adminToken";
/** Pull a string field out of the loosely-typed generation metadata blob. */
function metaString(
metadata: Record<string, unknown> | undefined,
key: string,
): string | undefined {
const v = metadata?.[key];
return typeof v === "string" && v.length > 0 ? v : undefined;
}
// Humanize a risk flag (e.g. "PROFANITY_DETECTED" -> "profanity detected").
function humanizeRiskFlag(flag: string): string {
return flag.toLowerCase().replace(/_/g, " ").trim();
}
// ---------------------------------------------------------------------------
// Token gate
// ---------------------------------------------------------------------------
function TokenGate({
token,
onSave,
onClear,
authError,
}: {
token: string | null;
onSave: (t: string) => void;
onClear: () => void;
authError: boolean;
}) {
const [draft, setDraft] = useState("");
const save = () => {
const trimmed = draft.trim();
if (trimmed) {
onSave(trimmed);
setDraft("");
}
};
return (
<section className="admin-card" aria-labelledby="admin-token-heading">
<h2 className="admin-card__heading" id="admin-token-heading">
Operator token
</h2>
{token ? (
<div className="admin-token-set">
<span className="admin-token-set__badge">admin token set</span>
<span className="admin-token-set__note">
Held in this tab only (sessionStorage). Sent as{" "}
<code>x-admin-token</code>. Never stored on the server or in the build.
</span>
<button type="button" className="admin-btn admin-btn--ghost" onClick={onClear}>
Clear token
</button>
</div>
) : (
<div className="admin-token-form">
<p className="admin-card__hint">
Paste your admin token to enable operator actions. It stays in this
browser tab only and is sent as the <code>x-admin-token</code> header.
</p>
<div className="admin-field-row">
<input
type="password"
className="admin-input"
placeholder="admin token"
autoComplete="off"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") save();
}}
aria-label="Admin token"
/>
<button
type="button"
className="admin-btn"
onClick={save}
disabled={!draft.trim()}
>
Save
</button>
</div>
</div>
)}
{authError && (
<p className="error admin-error" role="alert">
invalid or missing admin token paste a valid token to continue.
</p>
)}
</section>
);
}
// ---------------------------------------------------------------------------
// Generate panel
// ---------------------------------------------------------------------------
function GeneratePanel({
token,
onAuthError,
onGenerated,
generation,
}: {
token: string | null;
onAuthError: () => void;
onGenerated: (g: PrometheusGenerateResponse) => void;
generation: PrometheusGenerateResponse | null;
}) {
const [chaos, setChaos] = useState(0.5);
const [operatorSeed, setOperatorSeed] = useState("");
const [receiptId, setReceiptId] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const generate = useCallback(async () => {
if (!token) return;
setLoading(true);
setError(null);
try {
const body: {
receiptId?: string;
chaos?: number;
operatorSeed?: string;
} = { chaos };
if (operatorSeed.trim()) body.operatorSeed = operatorSeed.trim();
if (receiptId.trim()) body.receiptId = receiptId.trim();
const res = await fetch(`${API_BASE}/api/prometheus/generate`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-admin-token": token,
},
body: JSON.stringify(body),
});
if (res.status === 403) {
onAuthError();
return;
}
if (!res.ok) throw new Error(`Generation failed (${res.status})`);
const data = (await res.json()) as PrometheusGenerateResponse;
onGenerated(data);
} catch (e) {
setError(e instanceof Error ? e.message : "Generation failed.");
} finally {
setLoading(false);
}
}, [token, chaos, operatorSeed, receiptId, onAuthError, onGenerated]);
const tagline = useMemo(
() => metaString(generation?.metadata, "tagline"),
[generation],
);
const description = useMemo(
() => metaString(generation?.metadata, "description"),
[generation],
);
const imageUrl = useMemo(
() => metaString(generation?.metadata, "imageUrl"),
[generation],
);
return (
<section className="admin-card" aria-labelledby="admin-generate-heading">
<h2 className="admin-card__heading" id="admin-generate-heading">
Generate a Spawn
</h2>
<p className="admin-card__hint">
Drive Prometheus to produce a Spawn package for manual review. Generation
does nothing on-chain it only proposes a name, ticker, lore, and image.
</p>
<fieldset className="admin-fieldset" disabled={!token || loading}>
<label className="admin-field">
<span className="admin-field__label">
Chaos <span className="admin-field__value">{chaos.toFixed(2)}</span>
</span>
<input
type="range"
className="admin-slider"
min={0}
max={1}
step={0.01}
value={chaos}
onChange={(e) => setChaos(Number(e.target.value))}
/>
</label>
<label className="admin-field">
<span className="admin-field__label">Operator seed (optional)</span>
<input
type="text"
className="admin-input"
placeholder="e.g. molten phoenix, cursed embers…"
value={operatorSeed}
onChange={(e) => setOperatorSeed(e.target.value)}
/>
</label>
<label className="admin-field">
<span className="admin-field__label">Receipt id (optional)</span>
<input
type="text"
className="admin-input"
placeholder="burn receipt id to seed from"
value={receiptId}
onChange={(e) => setReceiptId(e.target.value)}
/>
</label>
<button type="button" className="admin-btn" onClick={generate}>
{loading ? "Generating…" : "Generate Spawn"}
</button>
</fieldset>
{!token && (
<p className="admin-card__hint admin-card__hint--muted">
Set an admin token above to generate.
</p>
)}
{loading && (
<div className="admin-loading" role="status" aria-live="polite">
<span className="scanner__spinner" aria-hidden="true" />
Summoning a Spawn from the embers
</div>
)}
{error && (
<p className="error admin-error" role="alert">
Something went wrong: {error}
</p>
)}
{generation && (
<div className="admin-result">
<div className="admin-result__head">
{imageUrl ? (
// Pollinations / generated image URLs render directly.
// eslint-disable-next-line @next/next/no-img-element
<img
className="admin-result__img"
src={imageUrl}
alt={`${generation.spawnName} artwork`}
/>
) : (
<div
className="admin-result__img admin-result__img--placeholder"
aria-hidden="true"
>
🔥
</div>
)}
<div className="admin-result__titles">
<h3 className="admin-result__name">
{generation.spawnName}{" "}
<span className="admin-result__ticker">${generation.ticker}</span>
</h3>
{tagline && <p className="admin-result__tagline">{tagline}</p>}
</div>
</div>
{(description || generation.lore) && (
<p className="admin-result__lore">{description ?? generation.lore}</p>
)}
{description && generation.lore && description !== generation.lore && (
<p className="admin-result__lore admin-result__lore--muted">
{generation.lore}
</p>
)}
<dl className="admin-result__meta">
<div className="admin-result__row">
<dt>Generation id</dt>
<dd>
<code>{generation.generationId}</code>
</dd>
</div>
<div className="admin-result__row">
<dt>Image prompt</dt>
<dd>{generation.imagePrompt}</dd>
</div>
</dl>
{generation.riskFlags.length > 0 ? (
<div className="admin-riskflags admin-riskflags--warn">
<span className="admin-riskflags__label">
Review these risk flags before launching:
</span>
<div className="admin-riskflags__chips">
{generation.riskFlags.map((f) => (
<span key={f} className="admin-chip admin-chip--warn" title={f}>
{humanizeRiskFlag(f)}
</span>
))}
</div>
</div>
) : (
<p className="admin-riskflags__none">No risk flags raised.</p>
)}
</div>
)}
</section>
);
}
// ---------------------------------------------------------------------------
// Launch panel
// ---------------------------------------------------------------------------
function truncate(addr: string): string {
if (addr.length <= 10) return addr;
return `${addr.slice(0, 4)}${addr.slice(-4)}`;
}
function LaunchPanel({
token,
generation,
onAuthError,
}: {
token: string | null;
generation: PrometheusGenerateResponse;
onAuthError: () => void;
}) {
const [mint, setMint] = useState("");
const [pumpfunUrl, setPumpfunUrl] = useState("");
const [launchTx, setLaunchTx] = useState("");
const [metadataUri, setMetadataUri] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [record, setRecord] = useState<SpawnRecord | null>(null);
// A fresh generation invalidates any previous launch result/inputs.
useEffect(() => {
setRecord(null);
setError(null);
}, [generation.generationId]);
const record_launch = useCallback(async () => {
if (!token) return;
setLoading(true);
setError(null);
try {
const body: {
generationId: string;
mint: string;
metadataUri?: string;
pumpfunUrl?: string;
launchTx?: string;
} = { generationId: generation.generationId, mint: mint.trim() };
if (metadataUri.trim()) body.metadataUri = metadataUri.trim();
if (pumpfunUrl.trim()) body.pumpfunUrl = pumpfunUrl.trim();
if (launchTx.trim()) body.launchTx = launchTx.trim();
const res = await fetch(`${API_BASE}/api/spawn/launch`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-admin-token": token,
},
body: JSON.stringify(body),
});
if (res.status === 403) {
onAuthError();
return;
}
if (res.status === 404) {
throw new Error("generation not found (404) — re-generate and retry.");
}
if (!res.ok) throw new Error(`Record launch failed (${res.status})`);
const data = (await res.json()) as SpawnRecord;
setRecord(data);
} catch (e) {
setError(e instanceof Error ? e.message : "Record launch failed.");
} finally {
setLoading(false);
}
}, [token, generation.generationId, mint, metadataUri, pumpfunUrl, launchTx, onAuthError]);
return (
<section className="admin-card" aria-labelledby="admin-launch-heading">
<h2 className="admin-card__heading" id="admin-launch-heading">
Record a Pump.fun launch
</h2>
<p className="admin-card__hint">
After you create the token on Pump.fun by hand, record its identifiers
here. PYRE never signs or creates the launch this only files the manual
result against the generation.
</p>
<fieldset className="admin-fieldset" disabled={!token || loading}>
<label className="admin-field">
<span className="admin-field__label">Generation id</span>
<input
type="text"
className="admin-input admin-input--readonly"
value={generation.generationId}
readOnly
aria-readonly="true"
/>
</label>
<label className="admin-field">
<span className="admin-field__label">Mint (required)</span>
<input
type="text"
className="admin-input"
placeholder="SPL mint address (base58)"
value={mint}
onChange={(e) => setMint(e.target.value)}
/>
</label>
<label className="admin-field">
<span className="admin-field__label">Pump.fun URL (optional)</span>
<input
type="text"
className="admin-input"
placeholder="https://pump.fun/…"
value={pumpfunUrl}
onChange={(e) => setPumpfunUrl(e.target.value)}
/>
</label>
<label className="admin-field">
<span className="admin-field__label">Launch tx (optional)</span>
<input
type="text"
className="admin-input"
placeholder="create transaction signature"
value={launchTx}
onChange={(e) => setLaunchTx(e.target.value)}
/>
</label>
<label className="admin-field">
<span className="admin-field__label">Metadata URI (optional)</span>
<input
type="text"
className="admin-input"
placeholder="ipfs:// or https:// metadata JSON"
value={metadataUri}
onChange={(e) => setMetadataUri(e.target.value)}
/>
</label>
<button
type="button"
className="admin-btn"
onClick={record_launch}
disabled={!mint.trim()}
>
{loading ? "Recording…" : "Record launch"}
</button>
</fieldset>
{error && (
<p className="error admin-error" role="alert">
Something went wrong: {error}
</p>
)}
{record && (
<div className="admin-record">
<p className="admin-record__headline">Launch recorded 🔥</p>
<dl className="admin-result__meta">
<div className="admin-result__row">
<dt>Record id</dt>
<dd>
<code>{record.id}</code>
</dd>
</div>
<div className="admin-result__row">
<dt>Spawn</dt>
<dd>
{record.spawnName} <span>${record.ticker}</span>
</dd>
</div>
<div className="admin-result__row">
<dt>Status</dt>
<dd>{record.status}</dd>
</div>
{record.mint && (
<div className="admin-result__row">
<dt>Mint</dt>
<dd title={record.mint}>
<code>{truncate(record.mint)}</code>
</dd>
</div>
)}
</dl>
{record.pumpfunUrl && (
<a
className="admin-link"
href={record.pumpfunUrl}
target="_blank"
rel="noreferrer noopener"
>
View on Pump.fun
</a>
)}
<a className="admin-link" href="/spawn">
See it on the public Spawn record
</a>
</div>
)}
</section>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
/**
* Operator-only admin console. Public route, but every useful action is gated
* server-side by the `x-admin-token` header — the page does nothing without a
* valid token. The token is pasted at runtime and kept ONLY in sessionStorage.
*/
export default function AdminPage() {
const [token, setToken] = useState<string | null>(null);
const [authError, setAuthError] = useState(false);
const [generation, setGeneration] = useState<PrometheusGenerateResponse | null>(
null,
);
// Hydrate the token from sessionStorage on mount (tab-scoped, never persisted
// beyond the session, never read from env or build output).
useEffect(() => {
try {
const stored = sessionStorage.getItem(TOKEN_KEY);
if (stored) setToken(stored);
} catch {
// sessionStorage may be unavailable (e.g. privacy mode) — ignore.
}
}, []);
const saveToken = useCallback((t: string) => {
try {
sessionStorage.setItem(TOKEN_KEY, t);
} catch {
// ignore storage failures; still keep it in memory for this session.
}
setToken(t);
setAuthError(false);
}, []);
const clearToken = useCallback(() => {
try {
sessionStorage.removeItem(TOKEN_KEY);
} catch {
// ignore
}
setToken(null);
}, []);
// A 403 means the stored token is invalid/missing: drop it and re-prompt.
const handleAuthError = useCallback(() => {
try {
sessionStorage.removeItem(TOKEN_KEY);
} catch {
// ignore
}
setToken(null);
setAuthError(true);
}, []);
return (
<main className="page">
<section className="admin" aria-labelledby="admin-heading">
<header className="admin__head">
<p className="hero__eyebrow">Operator console</p>
<h1 className="section-heading" id="admin-heading">
Prometheus admin
</h1>
<p className="admin__intro">
Operator-only. Drive Prometheus generation and record manual Pump.fun
launches. The flow is intentionally manual: Prometheus proposes a
Spawn, you review it, create the token on Pump.fun in your own wallet,
then file the result here. PYRE never holds keys and never signs a
launch.
</p>
</header>
<TokenGate
token={token}
onSave={saveToken}
onClear={clearToken}
authError={authError}
/>
<GeneratePanel
token={token}
onAuthError={handleAuthError}
onGenerated={(g) => {
setGeneration(g);
setAuthError(false);
}}
generation={generation}
/>
{generation && (
<LaunchPanel
token={token}
generation={generation}
onAuthError={handleAuthError}
/>
)}
</section>
<Footer />
</main>
);
}

View File

@@ -1057,3 +1057,342 @@ body {
.spawn-card__link:hover { .spawn-card__link:hover {
text-decoration: underline; text-decoration: underline;
} }
/* ---- Operator admin console (/admin) ---- */
.admin {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.admin__head {
text-align: center;
}
.admin__intro {
max-width: 42rem;
margin: 0.5rem auto 0;
color: var(--color-smoke);
font-size: 0.95rem;
line-height: 1.7;
}
.admin-card {
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.5rem 1.5rem 1.6rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.admin-card__heading {
font-size: 1.15rem;
font-weight: 700;
margin: 0;
}
.admin-card__hint {
color: var(--color-smoke);
font-size: 0.88rem;
line-height: 1.6;
margin: 0;
}
.admin-card__hint--muted {
font-style: italic;
opacity: 0.85;
}
.admin-card code,
.admin-result code,
.admin-record code {
font-family: ui-monospace, monospace;
font-size: 0.82em;
color: var(--color-ember-bright);
word-break: break-all;
}
/* Inputs / fields */
.admin-fieldset {
border: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.admin-fieldset:disabled {
opacity: 0.6;
}
.admin-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.admin-field__label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-weight: 600;
font-size: 0.88rem;
color: #f5ede6;
}
.admin-field__value {
font-variant-numeric: tabular-nums;
color: var(--color-ember-bright);
}
.admin-field-row {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
}
.admin-input {
flex: 1;
min-width: 0;
appearance: none;
background: var(--color-coal);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.5rem;
color: #f5ede6;
font-size: 0.95rem;
padding: 0.65rem 0.85rem;
width: 100%;
}
.admin-input:focus {
outline: none;
border-color: var(--color-ember);
box-shadow: 0 0 0 2px rgba(255, 87, 34, 0.2);
}
.admin-input::placeholder {
color: rgba(184, 169, 156, 0.6);
}
.admin-input--readonly {
color: var(--color-smoke);
font-family: ui-monospace, monospace;
font-size: 0.85rem;
}
.admin-slider {
width: 100%;
accent-color: var(--color-ember);
cursor: pointer;
}
/* Buttons */
.admin-btn {
appearance: none;
border: 1px solid var(--color-ember);
background: linear-gradient(180deg, var(--color-ember-bright), var(--color-ember));
color: #1a0d06;
font-weight: 700;
font-size: 0.95rem;
padding: 0 1.25rem;
height: 46px;
border-radius: 0.5rem;
cursor: pointer;
align-self: flex-start;
transition: filter 0.15s ease, opacity 0.15s ease;
}
.admin-btn:hover:not(:disabled) {
filter: brightness(1.1);
}
.admin-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.admin-btn--ghost {
background: transparent;
border-color: rgba(255, 255, 255, 0.18);
color: var(--color-smoke);
height: 40px;
}
.admin-btn--ghost:hover:not(:disabled) {
filter: none;
border-color: rgba(255, 138, 61, 0.5);
color: #f5ede6;
}
/* Token gate */
.admin-token-form,
.admin-token-set {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.admin-token-set {
align-items: flex-start;
}
.admin-token-set__badge {
display: inline-block;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.2rem 0.6rem;
border-radius: 999px;
color: #7be3a3;
background: rgba(60, 200, 120, 0.12);
border: 1px solid rgba(60, 200, 120, 0.35);
}
.admin-token-set__note {
color: var(--color-smoke);
font-size: 0.85rem;
line-height: 1.6;
}
.admin-error {
margin: 0;
text-align: left;
}
.admin-loading {
display: flex;
align-items: center;
gap: 0.6rem;
color: var(--color-smoke);
}
/* Generation / launch result */
.admin-result,
.admin-record {
margin-top: 0.5rem;
border: 1px solid rgba(255, 138, 61, 0.4);
background: linear-gradient(180deg, rgba(255, 87, 34, 0.1), rgba(26, 20, 18, 0.7));
border-radius: 0.85rem;
padding: 1.25rem 1.4rem 1.4rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.admin-result__head {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.admin-result__img {
width: 6rem;
height: 6rem;
flex-shrink: 0;
object-fit: cover;
border-radius: 0.6rem;
border: 1px solid rgba(255, 138, 61, 0.3);
}
.admin-result__img--placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
background: radial-gradient(80% 80% at 50% 30%, rgba(255, 87, 34, 0.2), var(--color-ash));
}
.admin-result__name {
margin: 0;
font-size: 1.3rem;
font-weight: 800;
color: #f5ede6;
}
.admin-result__ticker {
color: var(--color-ember-bright);
font-weight: 700;
font-size: 0.95rem;
}
.admin-result__tagline {
margin: 0.35rem 0 0;
color: var(--color-ember-bright);
font-weight: 600;
font-size: 0.95rem;
}
.admin-result__lore {
margin: 0;
color: #f5ede6;
font-size: 0.92rem;
line-height: 1.6;
}
.admin-result__lore--muted {
color: var(--color-smoke);
font-size: 0.85rem;
font-style: italic;
}
.admin-result__meta {
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.admin-result__row {
display: flex;
flex-direction: column;
gap: 0.15rem;
font-size: 0.85rem;
}
.admin-result__row dt {
color: var(--color-smoke);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.72rem;
font-weight: 600;
}
.admin-result__row dd {
margin: 0;
color: #f5ede6;
line-height: 1.5;
}
/* Risk flag chips */
.admin-riskflags {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.admin-riskflags--warn {
border: 1px solid rgba(255, 60, 40, 0.45);
background: rgba(255, 60, 40, 0.08);
border-radius: 0.6rem;
padding: 0.85rem 1rem;
}
.admin-riskflags__label {
color: #ff9a7a;
font-weight: 700;
font-size: 0.88rem;
}
.admin-riskflags__chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.admin-riskflags__none {
margin: 0;
color: var(--color-smoke);
font-size: 0.85rem;
font-style: italic;
}
.admin-chip {
display: inline-block;
font-size: 0.74rem;
font-weight: 600;
padding: 0.14rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
}
.admin-chip--warn {
color: #ff9a7a;
background: rgba(255, 60, 40, 0.12);
border: 1px solid rgba(255, 60, 40, 0.4);
}
/* Recorded launch */
.admin-record {
border-color: rgba(60, 200, 120, 0.4);
background: linear-gradient(180deg, rgba(60, 200, 120, 0.1), rgba(26, 20, 18, 0.7));
}
.admin-record__headline {
margin: 0;
font-size: 1.2rem;
font-weight: 800;
color: #7be3a3;
}
.admin-link {
align-self: flex-start;
color: var(--color-ember-bright);
font-weight: 600;
font-size: 0.9rem;
text-decoration: none;
}
.admin-link:hover {
text-decoration: underline;
}

View File

@@ -22,6 +22,7 @@ export function Footer() {
</a> </a>
<a href="#scanner">Scanner</a> <a href="#scanner">Scanner</a>
<a href="/spawn">The Spawn</a> <a href="/spawn">The Spawn</a>
<a href="/admin" rel="nofollow">Operator</a>
</nav> </nav>
<p className="footer__disclaimer"> <p className="footer__disclaimer">