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**:
**Solana transaction logic and business logic are NOT yet implemented.** Do not
add application/business logic unless explicitly asked.
This repo is **no longer scaffold + docs** — the burner core is implemented and
live at [feedthepyre.com](https://feedthepyre.com). Keep new work aligned with the
design doc; the trust rules above are non-negotiable.
**Explicitly OUT of scope for v0.1** (per §5):
**Built (working):**
- Automatic Pump.fun launch
- User-contributed Essence vault
- Custom PYRE Solana program (Anchor)
- NFT handling (incl. compressed NFTs)
- Automatic valuable-token sacrifice
- Custodial signing
- Background wallet automation
- On-chain swap routing (TRANSMUTABLE) and Token-2022 confidential-transfer /
fee-harvest flows
- **Wallet scan + conservative classifier** (`@pyre/core`): EMPTY_CLOSE_ONLY /
INCINERATE_ONLY / TRANSMUTABLE / PROTECTED_SKIP / UNSUPPORTED. "Unknown means
skip"; never "safe".
- **Token-2022** supported conservatively with account+mint **extension gating**
(§7.1): confidential transfer / withheld transfer fees / frozen / unknown
extensions skipped; transfer-hook & permanent-delegate mints cleanable but
flagged; unverifiable mints → UNSUPPORTED. In `@pyre/core` (`extensions.ts`,
`classify.ts`) + `@pyre/solana`.
- **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),
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.
**In progress:**
v0.1 ships: wallet connect → scan token accounts (classic SPL + Token-2022) →
classify → close eligible empty ATAs (optionally burn obvious junk) → return rent
to user → show receipt.
- Admin generation UI (review/approve Spawn packages).
- Generation-from-receipt wiring (`/api/prometheus/generate` already pulls
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
api/ # Fastify HTTP API: scan, classify, build tx, receipt,
# generation, admin endpoints
worker/ # BullMQ worker: async metadata lookup, AI generation,
# safety checks, tx-confirmation watcher, receipt enrichment
worker/ # BullMQ worker (SKELETON — jobs not yet wired): planned async
# metadata lookup, AI generation, safety, tx-confirm, enrichment
packages/
core/ # shared types & logic: classification enums, risk rules,
# DTOs, receipt schema, Prometheus I/O schema
@@ -127,12 +146,14 @@ Node 22 is required.
pnpm install # install workspace deps
pnpm -r build # build all packages/apps
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)
```
> **Nothing is installed yet.** These commands are not runnable until the
> skeleton's `package.json` files and dependencies exist.
Production runs under **pm2** (`ecosystem.config.cjs`: `pyre-web`, `pyre-api`,
`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/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:**
> Read CLAUDE.md and docs/PYRE_MVP_DESIGN.md. Do not write code yet. Produce an
> implementation plan for PYRE MVP v0.1 focused only on wallet scanning, token
> account classification, close-empty-ATA transaction building, transaction
> preview, and receipt generation. Identify the exact packages, APIs, database
> 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.
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
touching transaction-building or classification code, read the relevant section
of [`docs/PYRE_MVP_DESIGN.md`](docs/PYRE_MVP_DESIGN.md) (§6, §7, §8, §16) — that
document wins on any conflict. Run `pnpm -r typecheck` and `pnpm -r test` after
changes.

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)
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
helps you safely close empty associated token accounts (ATAs) and burn obvious
junk — **returning recovered rent to you** and producing a clear, shareable
receipt. A later layer (Prometheus) uses AI to generate a meme-token "Spawn" from
burned remnants for **manual, human-reviewed** launch.
a wallet; PYRE scans your SPL token accounts (classic **and** Token-2022),
classifies them conservatively, and helps you safely close empty associated token
accounts (ATAs) and burn obvious junk — **returning recovered rent to you** (minus
a transparent 5% protocol fee) and producing a clear, shareable receipt. The
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
forgot was trapped in token accounts."*
@@ -26,8 +30,10 @@ promises.
**Trust guarantees:** PYRE never holds your private keys, never signs
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
cannot safely reason about is skipped.
sign. Recovered rent goes back to your wallet, minus a single **transparent 5%
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
@@ -35,25 +41,34 @@ cannot safely reason about is skipped.
Connect wallet
→ scan token accounts
→ 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
→ recovered rent returns to you
→ recovered rent (minus the fee) returns to you; the fee feeds the PYRE
→ see your PYRE receipt
```
## Roadmap
## Roadmap & status
- **v0.1 — Burner / Cleaner** *(current focus)*: wallet connect, scan, classify,
close empty ATAs, optional junk burn, rent return, receipt.
- **v0.2 — Prometheus Meta Mixer**: AI generation of a Spawn identity from
burned/cleaned token context (candidate package only — no auto-launch).
- **v0.3Manual Pump.fun Workflow**: human reviews the Spawn package and
manually creates the token; PYRE records mint, URL, metadata, and tx.
- **v0.4 — Essence Ledger**: record net SOL value of safe scrap swaps as Essence
per wallet/round (database-only, experimental, no claim promises).
- **v1.0 — PYRE Core Program**: custom Solana (Anchor) program for trust-critical
accounting — rounds, Essence vault, contribution receipts, Spawn distribution,
claims, and refunds.
- **v0.1 — Burner / Cleaner** **working.** Wallet connect, scan, conservative
classification (classic SPL + Token-2022 with extension gating), close empty
ATAs, burn-then-close junk, rent return minus the transparent 5% fee, receipt.
Live on mainnet.
- **v0.2Prometheus Meta Mixer** — **working.** AI generation of a Spawn
identity (name/ticker/lore/tagline/image-prompt + safety) from burned/cleaned
token context; provider-abstracted with a deterministic stub fallback. Produces
a candidate package only — no auto-launch. *In progress:* admin review UI and
finishing the receipt → generation wiring.
- **v0.3 — Manual Pump.fun Workflow** — **working (manual, approval-gated).**
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
@@ -61,7 +76,7 @@ Connect wallet
apps/
web/ Next.js user app (wallet connect, scanner, preview, receipt)
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/
core/ shared types, classification enums, risk rules, schemas
solana/ token-account parsing, close/burn tx builders, decoder, simulation
@@ -75,17 +90,20 @@ docs/ design, architecture, security, classification
## 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.
```bash
cp .env.example .env # then fill in values (no private keys — by design)
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
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 {
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 href="#scanner">Scanner</a>
<a href="/spawn">The Spawn</a>
<a href="/admin" rel="nofollow">Operator</a>
</nav>
<p className="footer__disclaimer">