diff --git a/README.md b/README.md
index d5711c0..2413d74 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
> **Burn the dead. Feed the PYRE. Claim the Spawn.**
+**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
diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs
new file mode 100644
index 0000000..b2351f8
--- /dev/null
+++ b/ecosystem.config.cjs
@@ -0,0 +1,74 @@
+// 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.
+//
+// Once apps exist, start with:
+// pm2 start ecosystem.config.cjs
+// pm2 save # persist process list so `pm2 resurrect` works on boot
+//
+// PM2 is installed at user level: ~/.local/share/pnpm/bin/pm2
+// Logs go to /home/pyre/pyre/logs/ (rotated by infra/logrotate/pyre).
+//
+// Note on memory: the 8GB VPS is shared with postgres, redis, nginx, etc.,
+// so each process is capped at 400M via max_memory_restart.
+
+module.exports = {
+ apps: [
+ {
+ // Next.js frontend (production) — port 3000 per .env.example (WEB_PORT).
+ name: "pyre-web",
+ cwd: "apps/web",
+ script: "pnpm",
+ args: "start", // runs `next start` (requires a prior `pnpm build`)
+ instances: 1,
+ autorestart: true,
+ max_memory_restart: "400M",
+ env: {
+ NODE_ENV: "production",
+ PORT: 3000,
+ },
+ out_file: "/home/pyre/pyre/logs/pyre-web-out.log",
+ error_file: "/home/pyre/pyre/logs/pyre-web-err.log",
+ },
+ {
+ // Fastify HTTP API — port 4000 per .env.example (API_PORT).
+ // Runs the compiled server. Until a build exists you can temporarily
+ // swap to a dev runner: script: "pnpm", args: "dev"
+ name: "pyre-api",
+ cwd: "apps/api",
+ script: "node",
+ args: "dist/index.js",
+ instances: 1,
+ autorestart: true,
+ max_memory_restart: "400M",
+ env: {
+ NODE_ENV: "production",
+ PORT: 4000,
+ },
+ out_file: "/home/pyre/pyre/logs/pyre-api-out.log",
+ error_file: "/home/pyre/pyre/logs/pyre-api-err.log",
+ },
+ {
+ // BullMQ background worker (no HTTP port).
+ // Runs the compiled worker. Until a build exists you can temporarily
+ // swap to a dev runner: script: "pnpm", args: "dev"
+ name: "pyre-worker",
+ cwd: "apps/worker",
+ script: "node",
+ args: "dist/index.js",
+ instances: 1,
+ autorestart: true,
+ max_memory_restart: "400M",
+ env: {
+ NODE_ENV: "production",
+ },
+ out_file: "/home/pyre/pyre/logs/pyre-worker-out.log",
+ error_file: "/home/pyre/pyre/logs/pyre-worker-err.log",
+ },
+ ],
+};
diff --git a/infra/logrotate/pyre b/infra/logrotate/pyre
new file mode 100644
index 0000000..653651e
--- /dev/null
+++ b/infra/logrotate/pyre
@@ -0,0 +1,34 @@
+# PYRE / Prometheus Protocol — logrotate config.
+#
+# INERT until logs are actually being produced (PM2 apps running / nginx
+# serving). The paths below will simply be skipped (missingok) until then.
+#
+# Install (run as a privileged user):
+# sudo cp /home/pyre/pyre/infra/logrotate/pyre /etc/logrotate.d/pyre
+# sudo logrotate --debug /etc/logrotate.d/pyre # dry-run to validate
+#
+# copytruncate is used so PM2 and nginx keep writing to the same file handle
+# (no restart/reopen needed after rotation).
+
+# ---- PYRE app logs (written by PM2) ----------------------------------------
+/home/pyre/pyre/logs/*.log {
+ su pyre pyre
+ daily
+ rotate 14
+ compress
+ delaycompress
+ missingok
+ notifempty
+ copytruncate
+}
+
+# ---- nginx logs for feedthepyre ---------------------------------------------
+/var/log/nginx/feedthepyre.*.log {
+ daily
+ rotate 14
+ compress
+ delaycompress
+ missingok
+ notifempty
+ copytruncate
+}
diff --git a/infra/nginx/README.md b/infra/nginx/README.md
new file mode 100644
index 0000000..76bc089
--- /dev/null
+++ b/infra/nginx/README.md
@@ -0,0 +1,98 @@
+# nginx — feedthepyre.com virtual host
+
+This directory holds the nginx virtual host config for **feedthepyre.com**, the
+public domain for the PYRE / Prometheus Protocol MVP.
+
+- [`feedthepyre.com.conf`](feedthepyre.com.conf) — the vhost.
+
+## What it does now
+
+Right now the vhost serves a **static status dashboard** (a holding/status page)
+for both `feedthepyre.com` and `www.feedthepyre.com`.
+
+- Site root (`location /`) serves files from the webroot
+ `/var/www/feedthepyre/status` (with `index.html`), using
+ `try_files $uri $uri/ /index.html`.
+- The Next.js web app and Fastify API are **not** proxied yet — those blocks are
+ present but commented out.
+- An explicit `/.well-known/acme-challenge/` location is served from the same
+ webroot so Let's Encrypt HTTP-01 validation works even before certbot applies
+ its `--nginx` changes.
+- gzip is enabled for common text content types.
+
+Ports (per `.env.example`; design §11 names the processes but not their ports):
+
+| service | bind | env var |
+| -------------- | --------------- | ---------- |
+| web (Next.js) | 127.0.0.1:3000 | `WEB_PORT` |
+| api (Fastify) | 127.0.0.1:4000 | `API_PORT` |
+
+## How the provision script installs it
+
+The provisioning script (run with sudo on the PYRE VPS) is expected to:
+
+1. Copy `feedthepyre.com.conf` to `/etc/nginx/sites-available/feedthepyre.com`.
+2. Symlink it into the enabled set:
+ `ln -s /etc/nginx/sites-available/feedthepyre.com /etc/nginx/sites-enabled/feedthepyre.com`
+3. Ensure the webroot exists and has an index page:
+ `mkdir -p /var/www/feedthepyre/status` (drop an `index.html` in it).
+4. Validate and reload: `nginx -t && systemctl reload nginx`.
+
+These exact paths are a contract the script relies on — do not rename the
+install path or the webroot without updating the script too.
+
+> This config is file-only. Do not run nginx/sudo from this repo; the provision
+> script owns that.
+
+## How certbot adds TLS
+
+After the HTTP vhost is installed and nginx is reloaded, obtain certificates:
+
+```bash
+sudo certbot --nginx -d feedthepyre.com -d www.feedthepyre.com
+```
+
+certbot will **edit `feedthepyre.com.conf` in place**, adding:
+
+- a `listen 443 ssl;` server block,
+- `ssl_certificate` / `ssl_certificate_key` directives (and Let's Encrypt
+ includes),
+- an HTTP->HTTPS redirect on the `listen 80;` server.
+
+That is why the `:80` server is written as a plain `listen 80;` block with both
+domains in `server_name` — it is the shape certbot expects to augment. Renewals
+are handled automatically by certbot's systemd timer/cron.
+
+## Flipping from the static status page to the live app + API proxy
+
+Once `apps/web` (Next.js, port 3000) and `apps/api` (Fastify, port 4000) are
+deployed and running on the VPS:
+
+1. Edit `/etc/nginx/sites-available/feedthepyre.com`.
+2. **Enable the API proxy:** uncomment the `location /api/ { ... }` block. The
+ trailing slash on `proxy_pass http://127.0.0.1:4000/;` strips the `/api/`
+ prefix so the backend sees `/scan`, `/receipt`, etc.
+3. **Switch the root to the web app:** in `location / { ... }`, replace the
+ `try_files ...` line with the `proxy_pass http://127.0.0.1:3000;` block shown
+ in the inline comment (Host / X-Real-IP / X-Forwarded-For / X-Forwarded-Proto
+ plus the websocket Upgrade/Connection headers).
+4. **Add the websocket map** (one time) to the `http{}` block of
+ `/etc/nginx/nginx.conf`:
+
+ ```nginx
+ map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+ }
+ ```
+
+5. **Optional global hardening:** add `server_tokens off;` to the same `http{}`
+ block (kept out of the vhost on purpose so it is not duplicated).
+6. Validate and reload:
+
+ ```bash
+ sudo nginx -t && sudo systemctl reload nginx
+ ```
+
+Keep the `/.well-known/acme-challenge/` location in place so certificate
+renewals continue to work after the cutover.
diff --git a/infra/nginx/feedthepyre.com.conf b/infra/nginx/feedthepyre.com.conf
new file mode 100644
index 0000000..a3fbb11
--- /dev/null
+++ b/infra/nginx/feedthepyre.com.conf
@@ -0,0 +1,109 @@
+# ============================================================================
+# PYRE / Prometheus Protocol — nginx virtual host for feedthepyre.com
+# ----------------------------------------------------------------------------
+# Install path: /etc/nginx/sites-available/feedthepyre.com
+# (the provision script symlinks this into sites-enabled/)
+#
+# TLS: Managed by certbot. Run `certbot --nginx` AFTER this config is
+# installed — it will inject the listen 443 ssl server block,
+# the ssl_certificate / ssl_certificate_key lines, and the
+# HTTP->HTTPS redirect automatically. Do NOT hand-edit those in.
+#
+# App ports (see docs/PYRE_MVP_DESIGN.md §11 and .env.example):
+# web (Next.js) -> 127.0.0.1:3000 (WEB_PORT)
+# api (Fastify) -> 127.0.0.1:4000 (API_PORT)
+#
+# Current behaviour: serves the static status dashboard from
+# /var/www/feedthepyre/status. The reverse-proxy blocks below
+# are commented out until the apps are deployed.
+# ============================================================================
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name feedthepyre.com www.feedthepyre.com;
+
+ # --- Static status site (current site root) -----------------------------
+ root /var/www/feedthepyre/status;
+ index index.html;
+
+ # --- Logging ------------------------------------------------------------
+ access_log /var/log/nginx/feedthepyre.access.log;
+ error_log /var/log/nginx/feedthepyre.error.log;
+
+ # --- ACME HTTP-01 challenge --------------------------------------------
+ # Explicit so certbot's HTTP-01 validation works even before its --nginx
+ # tweaks are applied. ^~ ensures this wins over the regex/proxy locations.
+ location ^~ /.well-known/acme-challenge/ {
+ root /var/www/feedthepyre/status;
+ allow all;
+ }
+
+ # --- Basic hardening ----------------------------------------------------
+ # gzip for text-ish content types.
+ gzip on;
+ gzip_comp_level 5;
+ gzip_min_length 256;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_types
+ text/plain
+ text/css
+ text/xml
+ text/javascript
+ application/javascript
+ application/json
+ application/xml
+ application/rss+xml
+ image/svg+xml;
+
+ # NOTE: `server_tokens off;` is intentionally NOT set here — it belongs in
+ # the http{} block of /etc/nginx/nginx.conf so it applies globally. Set it
+ # there once rather than duplicating it per-vhost.
+
+ # --- Site root ----------------------------------------------------------
+ # Serve the static status dashboard for now.
+ #
+ # LATER: when apps/web (Next.js) is deployed, switch this location from the
+ # static status page to a reverse proxy. Replace the try_files body with:
+ #
+ # proxy_pass http://127.0.0.1:3000;
+ # proxy_http_version 1.1;
+ # proxy_set_header Host $host;
+ # proxy_set_header X-Real-IP $remote_addr;
+ # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ # proxy_set_header X-Forwarded-Proto $scheme;
+ # proxy_set_header Upgrade $http_upgrade;
+ # proxy_set_header Connection $connection_upgrade;
+ #
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # ------------------------------------------------------------------------
+ # REVERSE-PROXY BLOCKS — enable when apps are running
+ # ------------------------------------------------------------------------
+ # Uncomment the /api/ block below once apps/api (Fastify, port 4000) is up.
+ # The trailing slash on proxy_pass strips the /api/ prefix so the backend
+ # sees /scan, /receipt, etc.
+ #
+ # location /api/ {
+ # proxy_pass http://127.0.0.1:4000/;
+ # proxy_http_version 1.1;
+ # proxy_set_header Host $host;
+ # proxy_set_header X-Real-IP $remote_addr;
+ # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ # proxy_set_header X-Forwarded-Proto $scheme;
+ # proxy_set_header Upgrade $http_upgrade;
+ # proxy_set_header Connection $connection_upgrade;
+ # }
+ #
+ # The websocket Upgrade/Connection headers above rely on a $connection_upgrade
+ # map. Add this once in the http{} block of /etc/nginx/nginx.conf:
+ #
+ # map $http_upgrade $connection_upgrade {
+ # default upgrade;
+ # '' close;
+ # }
+ # ------------------------------------------------------------------------
+}
diff --git a/infra/status/README.md b/infra/status/README.md
new file mode 100644
index 0000000..f881c35
--- /dev/null
+++ b/infra/status/README.md
@@ -0,0 +1,38 @@
+# PYRE Status Dashboard
+
+A static, self-contained dark "ember"-themed page the team uses to track PYRE MVP
+progress. It is a **snapshot**, not live telemetry.
+
+## Files
+
+- **`status.json`** — the single source of truth. All content on the page
+ (phases, checklists, infra, dates, links) comes from here.
+- **`index.html`** — the prebuilt rendered page. Committed so the page works even
+ before anyone runs the generator. Self-contained: inline CSS, no external
+ requests, no JS.
+- **`README.md`** — this file.
+
+## Editing & regenerating
+
+1. Edit **`status.json`** — flip an item's `"done"` to `true`, update a phase
+ `"state"` (`todo` / `in_progress` / `done`), or bump `"updated"`.
+2. Regenerate the page from the repo root:
+
+ ```bash
+ node scripts/gen-status.mjs
+ ```
+
+ The generator is dependency-free (plain Node ESM, no npm install). It reads
+ `infra/status/status.json` and rewrites `infra/status/index.html`, recomputing
+ the overall % complete from the item done-counts. It prints the output path
+ when finished.
+
+3. Commit the regenerated `index.html` alongside the `status.json` change so the
+ prebuilt page stays consistent with the data.
+
+## Deployment
+
+The provision script deploys `infra/status/*` to `/var/www/feedthepyre/status`,
+and nginx serves it as the site root (`feedthepyre.com`) until the real PYRE app
+ships. Because `index.html` is prebuilt and self-contained, deployment is a plain
+file copy — no build step or generator run is required on the server.
diff --git a/infra/status/index.html b/infra/status/index.html
new file mode 100644
index 0000000..a17946f
--- /dev/null
+++ b/infra/status/index.html
@@ -0,0 +1,306 @@
+
+
+
+
+
+ PYRE / Prometheus Protocol — Status Dashboard
+
+
+
+
+
+
+
+
+
Overall MVP Progress
+ 12%
+
+
+ 6 of 50 phase deliverables complete
+
+
+
Development Phases
+
+
+
+ Phase 0 Server & Repo Setup
+ IN PROGRESS
+
+ 6 / 8 complete
+
+ - ✓VPS configured (pyre user, SSH key, root disabled, UFW, Fail2ban)
+ - ✓Claude Code installed
+ - ✓Repo initialized
+ - ✓pnpm workspace created
+ - ✓web/api/worker skeleton
+ - ○Postgres + Redis running
+ - ○nginx configured
+ - ✓Environment templates
+
+
+
+
+ Phase 1 Wallet Scanner
+ TODO
+
+ 0 / 6 complete
+
+ - ○Wallet connect frontend
+ - ○Scan endpoint
+ - ○Token account fetch
+ - ○Basic classification
+ - ○Scan results UI
+ - ○Protected/skipped UI
+
+
+
+
+ Phase 2 Close Empty ATAs
+ TODO
+
+ 0 / 6 complete
+
+ - ○Identify empty token accounts
+ - ○Build close-account tx
+ - ○Decode tx preview
+ - ○Wallet signing
+ - ○Confirmation tracking
+ - ○Receipt page
+
+
+
+
+ Phase 3 Burn Junk
+ TODO
+
+ 0 / 5 complete
+
+ - ○Incinerate-only classification
+ - ○Burn transaction builder
+ - ○Burn-then-close flow
+ - ○Stronger confirmations
+ - ○Receipt update
+
+
+
+
+ Phase 4 Prometheus Generator
+ TODO
+
+ 0 / 6 complete
+
+ - ○Generation input from receipt
+ - ○Meta mixer
+ - ○Spawn name/ticker/lore generation
+ - ○Image prompt generation
+ - ○Safety checks
+ - ○Admin approval UI
+
+
+
+
+ Phase 5 Manual Pump.fun Launch Workflow
+ TODO
+
+ 0 / 5 complete
+
+ - ○Approved Spawn package
+ - ○Metadata JSON
+ - ○Operator launch checklist
+ - ○Mint/url/tx record input
+ - ○Public Spawn record page
+
+
+
+
+ Phase 6 Essence / Round Prototype
+ TODO
+
+ 0 / 6 complete
+
+ - ○Safe swap candidate detection
+ - ○Route quote preview
+ - ○Net Essence estimate
+ - ○Round dashboard
+ - ○Contribution database record
+ - ○No claim promises until on-chain logic exists
+
+
+
+
+ Phase 7 PYRE Core Program
+ TODO
+
+ 0 / 8 complete
+
+ - ○Anchor program — create round
+ - ○Contribute Essence
+ - ○Contribution receipt PDA
+ - ○Lock round
+ - ○Register Spawn
+ - ○Claim Spawn
+ - ○Refund failed round
+ - ○Tests
+
+
+
+
+
Infrastructure
+
+
6 / 11 provisioned
+
+ - ✓Node.js 22
+ - ✓pnpm
+ - ✓Git + Gitea remote
+ - ✓DNS (feedthepyre.com)
+ - ✓Monorepo scaffold + docs
+ - ✓pnpm install + typecheck clean
+ - ○nginx
+ - ○PostgreSQL
+ - ○Redis
+ - ○PM2
+ - ○TLS (Let's Encrypt)
+
+
+
+
+
+
+
diff --git a/infra/status/status.json b/infra/status/status.json
new file mode 100644
index 0000000..6a78791
--- /dev/null
+++ b/infra/status/status.json
@@ -0,0 +1,128 @@
+{
+ "project": "PYRE / Prometheus Protocol",
+ "tagline": "Burn the dead. Feed the PYRE. Claim the Spawn.",
+ "repo": "https://git.lumiai.dev/RogueWave/pyre",
+ "domain": "https://feedthepyre.com",
+ "updated": "2026-05-31",
+ "phases": [
+ {
+ "id": 0,
+ "name": "Server & Repo Setup",
+ "state": "in_progress",
+ "items": [
+ { "label": "VPS configured (pyre user, SSH key, root disabled, UFW, Fail2ban)", "done": true },
+ { "label": "Claude Code installed", "done": true },
+ { "label": "Repo initialized", "done": true },
+ { "label": "pnpm workspace created", "done": true },
+ { "label": "web/api/worker skeleton", "done": true },
+ { "label": "Postgres + Redis running", "done": false },
+ { "label": "nginx configured", "done": false },
+ { "label": "Environment templates", "done": true }
+ ]
+ },
+ {
+ "id": 1,
+ "name": "Wallet Scanner",
+ "state": "todo",
+ "items": [
+ { "label": "Wallet connect frontend", "done": false },
+ { "label": "Scan endpoint", "done": false },
+ { "label": "Token account fetch", "done": false },
+ { "label": "Basic classification", "done": false },
+ { "label": "Scan results UI", "done": false },
+ { "label": "Protected/skipped UI", "done": false }
+ ]
+ },
+ {
+ "id": 2,
+ "name": "Close Empty ATAs",
+ "state": "todo",
+ "items": [
+ { "label": "Identify empty token accounts", "done": false },
+ { "label": "Build close-account tx", "done": false },
+ { "label": "Decode tx preview", "done": false },
+ { "label": "Wallet signing", "done": false },
+ { "label": "Confirmation tracking", "done": false },
+ { "label": "Receipt page", "done": false }
+ ]
+ },
+ {
+ "id": 3,
+ "name": "Burn Junk",
+ "state": "todo",
+ "items": [
+ { "label": "Incinerate-only classification", "done": false },
+ { "label": "Burn transaction builder", "done": false },
+ { "label": "Burn-then-close flow", "done": false },
+ { "label": "Stronger confirmations", "done": false },
+ { "label": "Receipt update", "done": false }
+ ]
+ },
+ {
+ "id": 4,
+ "name": "Prometheus Generator",
+ "state": "todo",
+ "items": [
+ { "label": "Generation input from receipt", "done": false },
+ { "label": "Meta mixer", "done": false },
+ { "label": "Spawn name/ticker/lore generation", "done": false },
+ { "label": "Image prompt generation", "done": false },
+ { "label": "Safety checks", "done": false },
+ { "label": "Admin approval UI", "done": false }
+ ]
+ },
+ {
+ "id": 5,
+ "name": "Manual Pump.fun Launch Workflow",
+ "state": "todo",
+ "items": [
+ { "label": "Approved Spawn package", "done": false },
+ { "label": "Metadata JSON", "done": false },
+ { "label": "Operator launch checklist", "done": false },
+ { "label": "Mint/url/tx record input", "done": false },
+ { "label": "Public Spawn record page", "done": false }
+ ]
+ },
+ {
+ "id": 6,
+ "name": "Essence / Round Prototype",
+ "state": "todo",
+ "items": [
+ { "label": "Safe swap candidate detection", "done": false },
+ { "label": "Route quote preview", "done": false },
+ { "label": "Net Essence estimate", "done": false },
+ { "label": "Round dashboard", "done": false },
+ { "label": "Contribution database record", "done": false },
+ { "label": "No claim promises until on-chain logic exists", "done": false }
+ ]
+ },
+ {
+ "id": 7,
+ "name": "PYRE Core Program",
+ "state": "todo",
+ "items": [
+ { "label": "Anchor program — create round", "done": false },
+ { "label": "Contribute Essence", "done": false },
+ { "label": "Contribution receipt PDA", "done": false },
+ { "label": "Lock round", "done": false },
+ { "label": "Register Spawn", "done": false },
+ { "label": "Claim Spawn", "done": false },
+ { "label": "Refund failed round", "done": false },
+ { "label": "Tests", "done": false }
+ ]
+ }
+ ],
+ "infra": [
+ { "label": "Node.js 22", "done": true },
+ { "label": "pnpm", "done": true },
+ { "label": "Git + Gitea remote", "done": true },
+ { "label": "DNS (feedthepyre.com)", "done": true },
+ { "label": "Monorepo scaffold + docs", "done": true },
+ { "label": "pnpm install + typecheck clean", "done": true },
+ { "label": "nginx", "done": false },
+ { "label": "PostgreSQL", "done": false },
+ { "label": "Redis", "done": false },
+ { "label": "PM2", "done": false },
+ { "label": "TLS (Let's Encrypt)", "done": false }
+ ]
+}
diff --git a/infra/systemd/pm2-pyre.service b/infra/systemd/pm2-pyre.service
new file mode 100644
index 0000000..ce2445b
--- /dev/null
+++ b/infra/systemd/pm2-pyre.service
@@ -0,0 +1,40 @@
+# PYRE / Prometheus Protocol — systemd unit to resurrect PM2 on boot.
+#
+# INERT until apps are deployed AND `pm2 save` has been run at least once:
+# `pm2 resurrect` only restores processes from the saved dump
+# (~/.pm2/dump.pm2). Without that dump it starts nothing.
+#
+# Install (run as a privileged user, once):
+# sudo cp /home/pyre/pyre/infra/systemd/pm2-pyre.service /etc/systemd/system/pm2-pyre.service
+# sudo systemctl daemon-reload
+# sudo systemctl enable pm2-pyre
+#
+# Then, after the apps are built and started:
+# pm2 start /home/pyre/pyre/ecosystem.config.cjs
+# pm2 save # <-- REQUIRED: writes the dump that resurrect reads
+#
+# This unit runs PM2 as the unprivileged `pyre` user (no root daemon).
+
+[Unit]
+Description=PM2 process manager for PYRE (user pyre)
+Documentation=https://pm2.keymetrics.io/
+After=network-online.target postgresql.service redis-server.service
+Wants=network-online.target
+
+[Service]
+Type=forking
+User=pyre
+LimitNOFILE=infinity
+LimitNPROC=infinity
+LimitCORE=infinity
+Environment=PATH=/home/pyre/.local/share/pnpm:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+Environment=PM2_HOME=/home/pyre/.pm2
+PIDFile=/home/pyre/.pm2/pm2.pid
+Restart=on-failure
+
+ExecStart=/home/pyre/.local/share/pnpm/bin/pm2 resurrect
+ExecReload=/home/pyre/.local/share/pnpm/bin/pm2 reload all
+ExecStop=/home/pyre/.local/share/pnpm/bin/pm2 kill
+
+[Install]
+WantedBy=multi-user.target
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 0000000..2f59c25
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,102 @@
+# PYRE — `scripts/`
+
+Operational scripts for the PYRE VPS (Ubuntu 24.04). These cover **Phase 0**
+(server + repo setup) per `docs/PYRE_MVP_DESIGN.md` §12, §18, §19.
+
+| Script | Purpose | Who runs it |
+| --- | --- | --- |
+| [`phase0-provision.sh`](./phase0-provision.sh) | Idempotent **root** provisioning: nginx, PostgreSQL, Redis, certbot/TLS, UFW, systemd pm2 unit, logrotate, status page. | root (`sudo`) |
+| `gen-status.mjs` | Generates the public `status.json` for the status page. *(Authored by another agent — see that file's header for usage.)* | `pyre` user |
+| `backup.sh` | Database / config backup routine. *(Authored by another agent — see that file's header for usage.)* | `pyre` user / cron |
+
+---
+
+## `phase0-provision.sh`
+
+Provisions all **system-level** services PYRE depends on. It is
+**idempotent and safe to re-run** — every step checks current state before
+changing anything.
+
+### What it does NOT do
+
+- Does **not** install Node.js 22, pnpm, the repo, or app `.env` files
+ (those are handled separately / by the `pyre` user).
+- Does **not** run a Solana validator/RPC node or any local LLM/image model
+ (explicitly out of scope per design §11/§12).
+- Does **not** change Postgres `listen_addresses` or expose Redis — both stay
+ **localhost-only**.
+- Does **not** store any wallet private key. PYRE never holds keys; there is
+ intentionally no key/mnemonic variable anywhere.
+
+### Steps
+
+1. `apt-get update` + install `nginx postgresql postgresql-contrib redis-server certbot python3-certbot-nginx ufw`.
+2. Enable + start `postgresql` and `redis-server`.
+3. Create Postgres role `pyre` (LOGIN, NOCREATEDB) and database `pyre` owned by it — only if absent. Matches `DATABASE_URL=postgresql://pyre:pyre@localhost:5432/pyre` from `.env.example`.
+4. Harden Redis: `bind 127.0.0.1 ::1` and `maxmemory-policy noeviction`, then restart.
+5. UFW: allow `22`, `2222`, `80`, `443` **before** enabling the firewall.
+6. Install the nginx vhost, remove the default site, validate, reload.
+7. Create the status-page webroot and copy in `index.html` / `status.json`.
+8. Obtain/renew the TLS certificate via certbot (nginx plugin), with redirect.
+9. Install the `pm2-pyre` systemd unit (enable, don't start) and the logrotate config.
+10. Print versions, service statuses, a checklist, and a reminder to set real secrets.
+
+### Prerequisites
+
+- Ubuntu 24.04 with the base setup already done (`pyre` user, SSH key auth,
+ root login disabled, Fail2ban) — per design §12.
+- The repo present at **`/home/pyre/pyre`**, owned by `pyre`.
+- The `infra/` config sources present (authored by other agents):
+ - `infra/nginx/feedthepyre.com.conf`
+ - `infra/status/index.html`, `infra/status/status.json`
+ - `infra/systemd/pm2-pyre.service`
+ - `infra/logrotate/pyre`
+ - If any are missing the script **warns and continues** rather than failing,
+ so you can re-run it once the sources exist.
+- **DNS must already point `feedthepyre.com` and `www.feedthepyre.com` at this
+ box** before the TLS step can succeed. If DNS isn't live yet, certbot is
+ skipped gracefully — just re-run the script later.
+
+### How to run
+
+```bash
+# Default (dev) — uses DB password "pyre" and the built-in certbot email:
+sudo bash scripts/phase0-provision.sh
+```
+
+It must run as **root**. If you forget `sudo`, it re-execs itself under `sudo`
+(or errors with instructions if `sudo` is unavailable).
+
+### Overridable environment variables
+
+| Variable | Default | Notes |
+| --- | --- | --- |
+| `PYRE_DB_PASSWORD` | `pyre` | Postgres password for role `pyre`. The default is **dev-only**; the script prints a loud warning when it's used. Set a real one for production. Only applied when the role is first created — re-runs do not change an existing role's password. |
+| `CERTBOT_EMAIL` | `a31s15.roguewave@gmail.com` | Let's Encrypt registration / expiry-notice email. |
+
+Pass overrides via `sudo -E` so the environment is preserved:
+
+```bash
+PYRE_DB_PASSWORD='a-strong-secret' \
+CERTBOT_EMAIL='ops@feedthepyre.com' \
+ sudo -E bash scripts/phase0-provision.sh
+```
+
+If you set a non-default `PYRE_DB_PASSWORD`, update `DATABASE_URL` in the
+per-app `.env` files to match.
+
+### ⚠️ UFW / SSH warning
+
+This script runs **over SSH** and enables the firewall. It deliberately allows
+the SSH ports **before** enabling UFW:
+
+- **`22/tcp` (system SSH)** and **`2222/tcp` (Gitea git-SSH)** MUST stay open.
+ If you remove these allow rules, you will **permanently lose remote access**
+ to the box. The script never removes existing allow rules; if you edit UFW
+ by hand, keep 22 and 2222 open.
+
+### After provisioning
+
+Set **real secrets** in the per-app `.env` files (copy from `.env.example`):
+`DATABASE_URL`, `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `IMAGE_GEN_API_KEY`,
+`ADMIN_API_TOKEN`, `SOLANA_RPC_URL`, etc. Never commit a real `.env`.
diff --git a/scripts/backup.sh b/scripts/backup.sh
new file mode 100644
index 0000000..0cc07fe
--- /dev/null
+++ b/scripts/backup.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+#
+# PYRE / Prometheus Protocol — PostgreSQL backup script.
+#
+# INERT until the `pyre` database exists and has data. Until then a run will
+# simply fail at pg_dump (no DB), which is harmless — nothing is deleted unless
+# pg_dump succeeds.
+#
+# What it does:
+# - dumps the `pyre` database (gzip) to /home/pyre/backups/
+# - prunes backups older than 14 days
+# Idempotent and safe to run from cron.
+#
+# DATABASE_URL is read from the environment if set, otherwise defaults to the
+# local pyre DB. Do NOT hardcode real passwords here — for non-trivial passwords
+# prefer a ~/.pgpass file (chmod 600) so the URL can omit the password.
+#
+# Example crontab (run as the `pyre` user, daily at 03:30):
+# 30 3 * * * /home/pyre/pyre/scripts/backup.sh >> /home/pyre/pyre/logs/backup.log 2>&1
+
+set -euo pipefail
+
+DATABASE_URL="${DATABASE_URL:-postgresql://pyre:pyre@localhost:5432/pyre}"
+BACKUP_DIR="${BACKUP_DIR:-/home/pyre/backups}"
+RETENTION_DAYS="${RETENTION_DAYS:-14}"
+
+TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
+OUTFILE="${BACKUP_DIR}/pyre-${TIMESTAMP}.sql.gz"
+
+mkdir -p "${BACKUP_DIR}"
+
+echo "[backup] $(date -Is) dumping database -> ${OUTFILE}"
+
+# --no-owner / --no-acl keeps the dump portable across roles when restoring.
+# Write to a temp file first, then atomically rename, so cron never prunes or
+# leaves behind a half-written archive.
+TMPFILE="${OUTFILE}.partial"
+pg_dump --no-owner --no-acl --dbname="${DATABASE_URL}" | gzip -c > "${TMPFILE}"
+mv "${TMPFILE}" "${OUTFILE}"
+
+echo "[backup] $(date -Is) wrote $(du -h "${OUTFILE}" | cut -f1) backup"
+
+# Prune backups older than RETENTION_DAYS (only PYRE dumps).
+echo "[backup] $(date -Is) pruning backups older than ${RETENTION_DAYS} days"
+find "${BACKUP_DIR}" -maxdepth 1 -type f -name 'pyre-*.sql.gz' \
+ -mtime "+${RETENTION_DAYS}" -print -delete || true
+
+echo "[backup] $(date -Is) done"
diff --git a/scripts/gen-status.mjs b/scripts/gen-status.mjs
new file mode 100644
index 0000000..fb040e3
--- /dev/null
+++ b/scripts/gen-status.mjs
@@ -0,0 +1,256 @@
+#!/usr/bin/env node
+// PYRE status dashboard generator.
+// Dependency-free: reads ../infra/status/status.json (relative to this file)
+// and renders ../infra/status/index.html.
+//
+// Usage (from repo root): node scripts/gen-status.mjs
+
+import { readFileSync, writeFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, resolve } from "node:path";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const dataPath = resolve(__dirname, "../infra/status/status.json");
+const outPath = resolve(__dirname, "../infra/status/index.html");
+
+/** Escape a string for safe insertion into HTML text/attribute context. */
+function esc(value) {
+ return String(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+const data = JSON.parse(readFileSync(dataPath, "utf8"));
+
+// --- compute overall % complete from item done-counts ---
+let totalItems = 0;
+let doneItems = 0;
+for (const phase of data.phases) {
+ for (const item of phase.items) {
+ totalItems += 1;
+ if (item.done) doneItems += 1;
+ }
+}
+const overallPct = totalItems === 0 ? 0 : Math.round((doneItems / totalItems) * 100);
+
+// --- state badge helpers ---
+const STATE_LABEL = {
+ done: "DONE",
+ in_progress: "IN PROGRESS",
+ todo: "TODO",
+};
+function stateLabel(state) {
+ return STATE_LABEL[state] || String(state).toUpperCase();
+}
+
+function renderItems(items) {
+ return items
+ .map((item) => {
+ const cls = item.done ? "item done" : "item";
+ const mark = item.done ? "✓" : "○"; // ✓ / ○
+ return ` ${mark}${esc(item.label)}`;
+ })
+ .join("\n");
+}
+
+function renderPhase(phase) {
+ const doneCount = phase.items.filter((i) => i.done).length;
+ const stateClass = esc(phase.state);
+ return `
+
+ Phase ${esc(phase.id)} ${esc(phase.name)}
+ ${esc(stateLabel(phase.state))}
+
+ ${doneCount} / ${phase.items.length} complete
+
+${renderItems(phase.items)}
+
+ `;
+}
+
+const phasesHtml = data.phases.map(renderPhase).join("\n");
+const infraHtml = renderItems(data.infra);
+const infraDone = data.infra.filter((i) => i.done).length;
+
+const html = `
+
+
+
+
+ ${esc(data.project)} — Status Dashboard
+
+
+
+
+
+
+
+
+
Overall MVP Progress
+ ${overallPct}%
+
+
+ ${doneItems} of ${totalItems} phase deliverables complete
+
+
+
Development Phases
+
+${phasesHtml}
+
+
+
Infrastructure
+
+
${infraDone} / ${data.infra.length} provisioned
+
+
+
+
+
+
+
+`;
+
+writeFileSync(outPath, html, "utf8");
+console.log(`Wrote ${outPath} (${overallPct}% complete, ${doneItems}/${totalItems} items)`);
diff --git a/scripts/phase0-provision.sh b/scripts/phase0-provision.sh
new file mode 100755
index 0000000..fa00a9b
--- /dev/null
+++ b/scripts/phase0-provision.sh
@@ -0,0 +1,394 @@
+#!/usr/bin/env bash
+# =============================================================================
+# PYRE — Phase 0 root provisioning script (Ubuntu 24.04)
+# =============================================================================
+# Idempotent, re-runnable. Provisions the system services PYRE needs:
+# nginx, PostgreSQL, Redis, certbot (TLS), UFW firewall, systemd unit for
+# pm2, logrotate, and the public status page.
+#
+# This script ONLY touches system-level config. It does NOT install Node.js,
+# pnpm, the repo, or app .env files (those are handled elsewhere / by the
+# pyre user). It must be run as root.
+#
+# Usage: sudo bash scripts/phase0-provision.sh
+#
+# Overridable environment variables:
+# PYRE_DB_PASSWORD Postgres password for role 'pyre' (default: "pyre")
+# CERTBOT_EMAIL email for Let's Encrypt (default below)
+#
+# Server base setup (pyre user, SSH key auth, root disabled, Fail2ban) is
+# assumed ALREADY DONE per design doc §12/§19. DNS for feedthepyre.com and
+# www.feedthepyre.com must already point at this box (required for TLS step).
+# =============================================================================
+
+set -euo pipefail
+export DEBIAN_FRONTEND=noninteractive
+
+# -----------------------------------------------------------------------------
+# Path / value contracts — other agents depend on these EXACT values.
+# -----------------------------------------------------------------------------
+PYRE_USER="pyre"
+REPO_DIR="/home/pyre/pyre"
+
+# Postgres — matches DATABASE_URL=postgresql://pyre:pyre@localhost:5432/pyre
+PYRE_DB_ROLE="pyre"
+PYRE_DB_NAME="pyre"
+PYRE_DB_PASSWORD="${PYRE_DB_PASSWORD:-pyre}" # dev-default; flagged loudly below
+
+# Domain / TLS
+DOMAIN="feedthepyre.com"
+WWW_DOMAIN="www.feedthepyre.com"
+CERTBOT_EMAIL="${CERTBOT_EMAIL:-a31s15.roguewave@gmail.com}"
+
+# nginx vhost
+NGINX_VHOST_SRC="${REPO_DIR}/infra/nginx/${DOMAIN}.conf"
+NGINX_VHOST_AVAIL="/etc/nginx/sites-available/${DOMAIN}"
+NGINX_VHOST_ENABLED="/etc/nginx/sites-enabled/${DOMAIN}"
+NGINX_DEFAULT_ENABLED="/etc/nginx/sites-enabled/default"
+
+# Status page
+STATUS_SRC_DIR="${REPO_DIR}/infra/status"
+STATUS_WEBROOT="/var/www/feedthepyre/status"
+STATUS_WEBROOT_PARENT="/var/www/feedthepyre"
+
+# systemd / logrotate sources
+SYSTEMD_UNIT_SRC="${REPO_DIR}/infra/systemd/pm2-pyre.service"
+SYSTEMD_UNIT_DST="/etc/systemd/system/pm2-pyre.service"
+LOGROTATE_SRC="${REPO_DIR}/infra/logrotate/pyre"
+LOGROTATE_DST="/etc/logrotate.d/pyre"
+
+REDIS_CONF="/etc/redis/redis.conf"
+
+# -----------------------------------------------------------------------------
+# Helpers
+# -----------------------------------------------------------------------------
+section() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
+info() { printf ' %s\n' "$*"; }
+warn() { printf '\033[1;33m[WARN] %s\033[0m\n' "$*" >&2; }
+err() { printf '\033[1;31m[ERROR] %s\033[0m\n' "$*" >&2; }
+
+# -----------------------------------------------------------------------------
+# Root check — re-exec under sudo if possible, else fail with instructions.
+# -----------------------------------------------------------------------------
+require_root() {
+ if [[ "${EUID}" -eq 0 ]]; then
+ return 0
+ fi
+ if command -v sudo >/dev/null 2>&1; then
+ warn "Not running as root — re-executing under sudo (you may be prompted for a password)..."
+ exec sudo -E bash "$0" "$@"
+ fi
+ err "This script must be run as root, and 'sudo' was not found."
+ err "Re-run as: sudo bash ${0}"
+ exit 1
+}
+
+# -----------------------------------------------------------------------------
+# 1. APT update + install base packages (apt install is idempotent).
+# -----------------------------------------------------------------------------
+step_apt() {
+ section "1/10 apt-get update + install base packages"
+ apt-get update -y
+ apt-get install -y \
+ nginx \
+ postgresql \
+ postgresql-contrib \
+ redis-server \
+ certbot \
+ python3-certbot-nginx \
+ ufw
+ info "Base packages installed (nginx, postgresql(+contrib), redis-server, certbot, python3-certbot-nginx, ufw)."
+}
+
+# -----------------------------------------------------------------------------
+# 2. Enable + start postgresql and redis-server.
+# -----------------------------------------------------------------------------
+step_services() {
+ section "2/10 Enable + start postgresql and redis-server"
+ systemctl enable --now postgresql
+ systemctl enable --now redis-server
+ info "postgresql: $(systemctl is-active postgresql 2>/dev/null || true)"
+ info "redis-server: $(systemctl is-active redis-server 2>/dev/null || true)"
+}
+
+# -----------------------------------------------------------------------------
+# 3. Postgres role + database (idempotent; localhost-only, do NOT change
+# listen_addresses).
+# -----------------------------------------------------------------------------
+step_postgres() {
+ section "3/10 PostgreSQL role + database"
+
+ if [[ "${PYRE_DB_PASSWORD}" == "pyre" ]]; then
+ warn "PYRE_DB_PASSWORD is the dev DEFAULT ('pyre'). This is fine for local/dev,"
+ warn "but set a real password for production: PYRE_DB_PASSWORD=... sudo -E bash $0"
+ fi
+
+ # Create role only if absent. LOGIN, no CREATEDB (db is created here as owner).
+ local role_exists
+ role_exists="$(sudo -u postgres psql -tAc \
+ "SELECT 1 FROM pg_roles WHERE rolname='${PYRE_DB_ROLE}'" || true)"
+ if [[ "${role_exists}" == "1" ]]; then
+ info "Role '${PYRE_DB_ROLE}' already exists — leaving it as-is (not changing password)."
+ else
+ info "Creating role '${PYRE_DB_ROLE}' (LOGIN, NOCREATEDB)."
+ # Password is passed via a parameter to avoid SQL-injection / quoting issues.
+ sudo -u postgres psql -v ON_ERROR_STOP=1 \
+ -v pw="${PYRE_DB_PASSWORD}" <<'SQL'
+CREATE ROLE pyre WITH LOGIN NOCREATEDB NOSUPERUSER NOCREATEROLE PASSWORD :'pw';
+SQL
+ fi
+
+ # Create database owned by pyre only if absent.
+ local db_exists
+ db_exists="$(sudo -u postgres psql -tAc \
+ "SELECT 1 FROM pg_database WHERE datname='${PYRE_DB_NAME}'" || true)"
+ if [[ "${db_exists}" == "1" ]]; then
+ info "Database '${PYRE_DB_NAME}' already exists — leaving it as-is."
+ else
+ info "Creating database '${PYRE_DB_NAME}' owned by '${PYRE_DB_ROLE}'."
+ sudo -u postgres createdb -O "${PYRE_DB_ROLE}" "${PYRE_DB_NAME}"
+ fi
+
+ info "DATABASE_URL should be: postgresql://${PYRE_DB_ROLE}:@localhost:5432/${PYRE_DB_NAME}"
+ info "(listen_addresses left untouched — PostgreSQL stays localhost-only.)"
+}
+
+# -----------------------------------------------------------------------------
+# 4. Redis — bind localhost-only + noeviction. Never expose publicly.
+# -----------------------------------------------------------------------------
+ensure_conf_line() {
+ # ensure_conf_line
+ # If a line matching the key exists (commented or not), replace it.
+ # Otherwise append the desired line. Idempotent.
+ local file="$1" key="$2" line="$3"
+ if grep -Eq "^[#[:space:]]*${key}" "${file}"; then
+ # Replace any existing (active or commented) occurrence with the desired line.
+ sed -i -E "s|^[#[:space:]]*${key}.*|${line}|" "${file}"
+ else
+ printf '%s\n' "${line}" >>"${file}"
+ fi
+}
+
+step_redis() {
+ section "4/10 Redis hardening (localhost-only, noeviction)"
+ if [[ ! -f "${REDIS_CONF}" ]]; then
+ err "Redis config not found at ${REDIS_CONF} — is redis-server installed?"
+ return 1
+ fi
+
+ # Bind to loopback only (IPv4 + IPv6). NEVER expose Redis to the public net.
+ ensure_conf_line "${REDIS_CONF}" "bind" "bind 127.0.0.1 ::1"
+ # PYRE uses Redis as a durable job queue (BullMQ); do not silently evict keys.
+ ensure_conf_line "${REDIS_CONF}" "maxmemory-policy" "maxmemory-policy noeviction"
+
+ systemctl restart redis-server
+ info "Redis bound to 127.0.0.1/::1 with maxmemory-policy noeviction; restarted."
+}
+
+# -----------------------------------------------------------------------------
+# 5. UFW firewall.
+# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+# !! THIS SCRIPT RUNS OVER SSH. Ports 22 (system SSH) and 2222 (Gitea !!
+# !! git-SSH) MUST stay open, or we permanently lose remote access to !!
+# !! the box. Allow rules are added BEFORE enabling the firewall. !!
+# !! Never remove existing allow rules here. !!
+# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+# -----------------------------------------------------------------------------
+step_ufw() {
+ section "5/10 UFW firewall (open SSH BEFORE enabling)"
+
+ # Open required ports first. 'ufw allow' is idempotent (re-adding is a no-op).
+ ufw allow 22/tcp comment 'system SSH - MUST stay open'
+ ufw allow 2222/tcp comment 'Gitea git-SSH - MUST stay open'
+ ufw allow 80/tcp comment 'HTTP (nginx / ACME)'
+ ufw allow 443/tcp comment 'HTTPS (nginx)'
+
+ # Enable only if currently inactive so we never toggle/reset an active firewall.
+ if ufw status 2>/dev/null | grep -q "Status: active"; then
+ info "UFW already active — left enabled, rules ensured."
+ else
+ warn "Enabling UFW now. SSH (22) and Gitea (2222) are already allowed above."
+ ufw --force enable
+ fi
+ ufw status verbose || true
+}
+
+# -----------------------------------------------------------------------------
+# 6. nginx vhost.
+# -----------------------------------------------------------------------------
+step_nginx() {
+ section "6/10 nginx vhost for ${DOMAIN}"
+
+ if [[ ! -f "${NGINX_VHOST_SRC}" ]]; then
+ err "nginx vhost source not found: ${NGINX_VHOST_SRC}"
+ err "(It is authored under infra/nginx/ by another agent. Skipping nginx config.)"
+ return 1
+ fi
+
+ install -m 0644 "${NGINX_VHOST_SRC}" "${NGINX_VHOST_AVAIL}"
+ ln -sfn "${NGINX_VHOST_AVAIL}" "${NGINX_VHOST_ENABLED}"
+ info "Installed vhost -> ${NGINX_VHOST_AVAIL} and symlinked into sites-enabled."
+
+ # Remove the stock default site if present.
+ if [[ -e "${NGINX_DEFAULT_ENABLED}" || -L "${NGINX_DEFAULT_ENABLED}" ]]; then
+ rm -f "${NGINX_DEFAULT_ENABLED}"
+ info "Removed default enabled site (${NGINX_DEFAULT_ENABLED})."
+ fi
+
+ nginx -t
+ systemctl reload nginx
+ info "nginx config valid; reloaded."
+}
+
+# -----------------------------------------------------------------------------
+# 7. Status page webroot.
+# -----------------------------------------------------------------------------
+step_status_page() {
+ section "7/10 Status page webroot (${STATUS_WEBROOT})"
+
+ install -d -m 0755 "${STATUS_WEBROOT}"
+
+ local copied=0
+ local f
+ for f in index.html status.json; do
+ if [[ -f "${STATUS_SRC_DIR}/${f}" ]]; then
+ install -m 0644 "${STATUS_SRC_DIR}/${f}" "${STATUS_WEBROOT}/${f}"
+ copied=$((copied + 1))
+ else
+ warn "Status source missing: ${STATUS_SRC_DIR}/${f} (authored by another agent)."
+ fi
+ done
+
+ chown -R www-data:www-data "${STATUS_WEBROOT_PARENT}"
+ info "Status webroot ready; copied ${copied} file(s); chowned to www-data."
+}
+
+# -----------------------------------------------------------------------------
+# 8. TLS via certbot (nginx plugin). Re-run-safe.
+# -----------------------------------------------------------------------------
+step_tls() {
+ section "8/10 TLS certificate via certbot (${DOMAIN}, ${WWW_DOMAIN})"
+
+ if ! command -v certbot >/dev/null 2>&1; then
+ err "certbot not found — skipping TLS."
+ return 1
+ fi
+
+ # certbot --nginx is re-run-safe: if a cert already covers these domains it
+ # will reuse/expand rather than fail. --non-interactive prevents prompts.
+ if certbot --nginx \
+ -d "${DOMAIN}" -d "${WWW_DOMAIN}" \
+ --non-interactive --agree-tos \
+ -m "${CERTBOT_EMAIL}" \
+ --redirect \
+ --keep-until-expiring; then
+ info "certbot succeeded (cert obtained or already present)."
+ systemctl reload nginx
+ info "nginx reloaded with TLS."
+ else
+ warn "certbot did not complete successfully. This is usually because DNS for"
+ warn "${DOMAIN}/${WWW_DOMAIN} is not yet pointing at this box, or port 80 is"
+ warn "unreachable. Provisioning continues; re-run this script after DNS is live."
+ fi
+}
+
+# -----------------------------------------------------------------------------
+# 9. systemd unit (pm2) + logrotate.
+# -----------------------------------------------------------------------------
+step_systemd_logrotate() {
+ section "9/10 systemd unit (pm2-pyre) + logrotate"
+
+ # App log directory — pm2 processes and scripts/backup.sh write here, and
+ # infra/logrotate/pyre rotates it. Created up-front so those don't fail.
+ install -d -m 0755 -o "${PYRE_USER}" -g "${PYRE_USER}" "${REPO_DIR}/logs"
+ info "Ensured app log dir ${REPO_DIR}/logs (owner ${PYRE_USER})."
+
+ # --- systemd pm2 unit ---
+ if [[ -f "${SYSTEMD_UNIT_SRC}" ]]; then
+ install -m 0644 "${SYSTEMD_UNIT_SRC}" "${SYSTEMD_UNIT_DST}"
+ systemctl daemon-reload
+ # Enable so it starts on boot. Do NOT fail if pm2 currently has no apps:
+ # 'enable' only registers the unit; we deliberately do not start it here.
+ if systemctl enable pm2-pyre.service 2>/dev/null; then
+ info "Installed + enabled pm2-pyre.service (not started here; ok if pm2 has no apps yet)."
+ else
+ warn "Could not enable pm2-pyre.service (continuing; check the unit later)."
+ fi
+ else
+ warn "systemd unit source missing: ${SYSTEMD_UNIT_SRC} (authored by another agent)."
+ fi
+
+ # --- logrotate ---
+ if [[ -f "${LOGROTATE_SRC}" ]]; then
+ install -m 0644 "${LOGROTATE_SRC}" "${LOGROTATE_DST}"
+ info "Installed logrotate config -> ${LOGROTATE_DST}."
+ else
+ warn "logrotate source missing: ${LOGROTATE_SRC} (authored by another agent)."
+ fi
+}
+
+# -----------------------------------------------------------------------------
+# 10. Final summary.
+# -----------------------------------------------------------------------------
+svc_status() { systemctl is-active "$1" 2>/dev/null || echo "unknown"; }
+
+step_summary() {
+ section "10/10 Summary"
+
+ echo " Versions:"
+ command -v nginx >/dev/null 2>&1 && info "nginx: $(nginx -v 2>&1)"
+ command -v psql >/dev/null 2>&1 && info "postgres: $(psql --version 2>/dev/null)"
+ command -v redis-server >/dev/null 2>&1 && info "redis-server: $(redis-server --version 2>/dev/null)"
+ command -v certbot >/dev/null 2>&1 && info "certbot: $(certbot --version 2>&1)"
+
+ echo
+ echo " Service status:"
+ info "postgresql: $(svc_status postgresql)"
+ info "redis-server: $(svc_status redis-server)"
+ info "nginx: $(svc_status nginx)"
+ info "ufw: $(ufw status 2>/dev/null | head -n1 || echo unknown)"
+ info "pm2-pyre: enabled=$(systemctl is-enabled pm2-pyre.service 2>/dev/null || echo no) active=$(svc_status pm2-pyre)"
+
+ echo
+ echo " Checklist (this run):"
+ info "[x] base packages installed (nginx, postgres, redis, certbot, ufw)"
+ info "[x] postgresql + redis-server enabled & started"
+ info "[x] postgres role '${PYRE_DB_ROLE}' + db '${PYRE_DB_NAME}' ensured (localhost-only)"
+ info "[x] redis bound to 127.0.0.1/::1, maxmemory-policy noeviction"
+ info "[x] UFW: 22, 2222, 80, 443 allowed (22 & 2222 MUST stay open)"
+ info "[x] nginx vhost installed + default site removed (if sources present)"
+ info "[x] status page webroot ${STATUS_WEBROOT} (if sources present)"
+ info "[x] TLS via certbot (skipped gracefully if DNS not ready)"
+ info "[x] pm2-pyre.service + logrotate installed (if sources present)"
+
+ echo
+ warn "REMINDER: set REAL secrets in the per-app .env files (copy from .env.example):"
+ warn " - DATABASE_URL (with the real PYRE_DB_PASSWORD if you changed it)"
+ warn " - ANTHROPIC_API_KEY / OPENAI_API_KEY / IMAGE_GEN_API_KEY"
+ warn " - ADMIN_API_TOKEN, SOLANA_RPC_URL, etc."
+ warn " PYRE never stores wallet private keys — there is intentionally no key var."
+ echo
+ info "Done. Safe to re-run this script any time."
+}
+
+# -----------------------------------------------------------------------------
+# Main
+# -----------------------------------------------------------------------------
+main() {
+ require_root "$@"
+ section "PYRE Phase 0 provisioning — $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
+ info "Repo: ${REPO_DIR} (owner: ${PYRE_USER}) Domain: ${DOMAIN}"
+
+ step_apt
+ step_services
+ step_postgres
+ step_redis
+ step_ufw
+ step_nginx
+ step_status_page
+ step_tls
+ step_systemd_logrotate
+ step_summary
+}
+
+main "$@"