Full-stack deployment workflow for Alex's self-hosted VPS infrastructure.
| Item | Value |
|---|---|
| Main VPS IP | 5.78.190.92 |
| User | avalon |
| Sudo/root access | May require interactive/root authorization; do not assume passwordless sudo is available from Hermes. If sudo -n true reports a password is required, avoid making /etc/nginx changes unless root access is available, and report the exact blocked edit. |
| Domain | *.apps.poofc.com (wildcard DNS) |
| Apps directory | /home/avalon/apps/ |
| PM2 ecosystem | /home/avalon/apps/ecosystem.config.js |
| Nginx configs | /etc/nginx/sites-available/ |
| Nginx auth | admin / SecurePass123! |
| GitHub author | firemountain (firemountain@gmail.com) |
| GitHub token | ~/.openclaw/credentials/github |
For provisioning additional Hetzner Cloud VPS nodes when hcloud is absent, use the direct API workflow in references/hetzner-cloud-api-provisioning.md. Distinguish Hetzner Cloud API tokens from Hetzner Object Storage/S3 credentials.
When the root disk fills up (>90%), the cheapest non-disruptive fix is a Hetzner Cloud Volume mounted at /data plus symlink-migrating heavy dirs (and Docker data-root) onto it. Full recipe, pricing, and pitfalls (including the restart=no container gotcha post-migration) in references/hetzner-volume-disk-expansion.md.
For Railway-hosted projects in Alex's or partner orgs (Exxir's Projects → Exxir App, Alex's personal projects), the Railway CLI is installed at ~/.npm-global/bin/railway. The auth env var is RAILWAY_API_TOKEN, NOT RAILWAY_TOKEN — every official doc snippet uses RAILWAY_TOKEN but that only works for project-scoped tokens; account/team tokens (the "no workspace" option in the Railway UI, which reaches all workspaces) require RAILWAY_API_TOKEN. The token is saved to ~/.hermes/.env. Useful one-liners:
# List all projects across workspaces
RAILWAY_API_TOKEN=... railway list
# Discover services + deploy state + domains via GraphQL (works without `railway link`):
curl -s -X POST https://backboard.railway.com/graphql/v2 \
-H "Authorization: Bearer $RAILWAY_API_TOKEN" -H "Content-Type: application/json" \
-d '{"query":"query { project(id: \"<PROJECT_ID>\") { services { edges { node { name serviceInstances { edges { node { source { repo image } domains { serviceDomains { domain } customDomains { domain } } latestDeployment { status createdAt staticUrl } } } } } } } } }"}'
Note that the GraphQL schema does not expose source directly on Service — it's on serviceInstances.edges.node.source. Using Service { source } errors with Cannot query field "source" on type "Service".
App N gets: - API port: 3000 + N - Client port: 4000 + N
Current allocations: - hello-1: 3001/4001 - hello-2: 3002/4002 - dashboard: 4003 - todo-app: 4004 - meta-leads: 3003 - dolphin-rider: 4005 (static game, no API)
hermes-<app-name>/ # GitHub: private repo
├── client/ # Next.js frontend
│ ├── pages/
│ ├── components/
│ │ └── LogPanel.tsx # MANDATORY slide-out log panel
│ └── package.json
├── server/ # Express backend
│ ├── src/
│ │ ├── utils/logger.js # Pino logger
│ │ ├── api/logs.js # WebSocket log streaming
│ │ └── index.js
│ ├── database.db
│ └── package.json
├── README.md
└── .gitignore
pm2 listGITHUB_TOKEN=$(cat ~/.openclaw/credentials/github)
curl -H "Authorization: token $GITHUB_TOKEN" \
-d '{"name":"hermes-APP_NAME","private":true}' \
https://api.github.com/user/repos
server/'::' for IPv6 compatibilityclient/NEXT_PUBLIC_API_URL to https://APP.apps.poofc.com/apicp -r .next/static .next/standalone/.next/# Add to PM2 ecosystem or start directly
pm2 start server/src/index.js --name "APP-server" -- --port 300N
pm2 start client/.next/standalone/server.js --name "APP-client" -- --port 400N
pm2 save
# Create nginx config
sudo cp /etc/nginx/sites-available/TEMPLATE /etc/nginx/sites-available/APP.apps.poofc.com
# Edit with correct ports and server_name
sudo ln -s /etc/nginx/sites-available/APP.apps.poofc.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
# SSL
sudo certbot --nginx -d APP.apps.poofc.com
git add .
git commit -m "descriptive message"
git push origin main
server {
listen 80;
listen [::]:80;
server_name APP.apps.poofc.com;
# Basic auth (optional)
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;
location /api {
proxy_pass http://localhost:300N;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
location /socket.io {
proxy_pass http://localhost:300N;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
location / {
proxy_pass http://localhost:400N;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
origin: (origin, callback) => {
if (!origin) return callback(null, true);
if (origin.endsWith('.poofc.com') || origin === 'http://localhost:3000') {
return callback(null, true);
}
callback(new Error('Not allowed by CORS'));
}
GET /api/items # List all
GET /api/items/:id # Get one
POST /api/items # Create {title, content}
PUT /api/items/:id # Update {title, content}
DELETE /api/items/:id # Delete
Every app MUST have: 1. Pino logger — JSON format, log all CRUD operations 2. Socket.IO — stream logs to authenticated clients 3. LogPanel component — slide-out drawer, newest logs on top, filters, pause/resume 4. Nginx auth — only authenticated users access logs 5. Security — never log tokens/passwords, sanitize PII
sudo nginx -t first, and after reload, spot-check 2-3 existing apps with curl -s -o /dev/null -w "%{http_code}" https://APP.apps.poofc.com -u admin:SecurePass123! to confirm nothing brokenginx -t before systemctl reload nginx — NEVER reload if the test fails. A failed reload leaves nginx down for ALL appslauncher.apps.poofc.com for every new deployed app — the launcher is Alex's canonical index for live VPS apps. After deploying a new app, update /home/avalon/apps/app-launcher/index.html with a visible card/link (or regenerate from live inventory), verify locally with curl --resolve launcher.apps.poofc.com:443:127.0.0.1 https://launcher.apps.poofc.com/, then commit and push firemountain/app-launcher. Do not let new apps exist only as standalone subdomains.public/app-icon.png plus generated 512/192/180 variants), use the same icon in the PWA manifest/apple-touch-icon, and surface it on launcher.apps.poofc.com. For launcher entries, also add/update the launcher's local icon asset (/home/avalon/apps/app-launcher/icons/<slug>.png) and app metadata (apps.json or the equivalent inventory source) so the launcher remains a phone-style grid of app icons./home/avalon/apps/app-launcherfiremountain/app-launcherhttps://launcher.apps.poofc.com/etc/nginx/sites-available/launcher.apps.poofc.comapps.poofc.com has historically lacked a public A/AAAA record; wildcard DNS covers *.apps.poofc.com but not the bare apps.poofc.com, so use launcher.apps.poofc.com unless DNS is deliberately changed./home/avalon/apps directories, pm2 jlist process data, and nginx enabled server names/proxies. Include broken/502 nginx routes in the launcher rather than hiding them, clearly marked as issues.status.json) over a long-running daemon. The snapshot script should use pm2 jlist, ps, load/disk checks, and /var/run/reboot-required, write atomically, and work under cron's minimal PATH.curl --resolve launcher.apps.poofc.com:443:127.0.0.1 https://launcher.apps.poofc.com/, validate apps.json/status.json, confirm representative icon URLs return image/png, and run sudo nginx -t before any nginx reload.status: "issue" and tags such as nginx pending, then commit/push the launcher so the app is discoverable without falsely marking the public route live.references/app-launcher-icon-grid-monitor.md for the icon-grid/press-hold/monitor implementation pattern and verification commands.references/no-sudo-new-app-deployment.md for the no-root new-app completion pattern: PM2/local verification + launcher issue entry + ready-to-apply /tmp nginx config + exact root commands.references/vite-express-app-skeleton-no-sudo-case.md for a concrete Vite+React+Express+SQLite skeleton pattern with app-level auth, PM2 local verification, launcher issue entry, /tmp nginx config, and Vitest/TypeScript pitfalls discovered while scaffolding Hermes Trader.references/safe-nginx-port-activation.md for the sudo/root follow-up pattern once the password is available: verify PM2 app/port ownership as avalon, then use sudo only for nginx/certbot/reload and spot-check existing apps.references/astral-spawn-stt-config.md for voice-note/STT routing across Hermes + Astral + Spawn (env vars, tenant config.yaml template, backfill caveat, swap path to Groq).references/hermes-spawn-control-plane.md for the Hermes Spawn pattern: a general-purpose Hermes tenant factory derived from Astral, with separate PM2/port/tenant roots, spawn-tenant-* containers, skill selection/curtail policy, KB builder scaffolding, gated self-registration/provisioning, mandatory Telegram allowed user IDs, nginx-pending handling when sudo is unavailable, and the control-plane Codex device-auth workaround for OpenAI/Cloudflare worker-IP 403/530 errors. For Spawn KB ingestion UX specifically, use the llm-wiki reference references/spawn-kb-ingestion-sessions.md: ingestion must be a visible durable session with scoped progress/reporting, not a hidden one-shot command; verify requested --skills exist in the tenant before invoking Hermes.| App | Subdomain | API Port | Client Port |
|---|---|---|---|
| hello-1 | apps.poofc.com | 3001 | 4001 |
| hello-2 | — | 3002 | 4002 |
| dashboard | dashboard.apps.poofc.com | — | 4003 |
| todo-app | — | — | 4004 |
| meta-leads | leads.apps.poofc.com | 3003 | 3000 |
| bingo | bingo.apps.poofc.com | — | — |
| hd-prism | hd-prism.apps.poofc.com | — | — |
| portfolio-demo | portfolio-demo.apps.poofc.com | — | — |
| video-censor | video-censor.apps.poofc.com | — | — |
| dolphin-rider | dolphin-rider.apps.poofc.com | — | 4005 |
| mocha-pod | mocha-pod.apps.poofc.com | — | 4006 |
| jungle-studio | jungle-studio.apps.poofc.com | — | 4010 (single port, Vite+Express) |
| street-fighter | street-fighter.apps.poofc.com | — | 4012 (static game) |
| street-fighter-2 | street-fighter-2.apps.poofc.com | — | 4013 (static game) |
| jungle-mobile | jungle-mobile.apps.poofc.com | — | 4011 (Vite+Express PWA, shares jungle-studio DB) |
| todos | todos.apps.poofc.com | — | 4014 (Vite+Express+SQLite, single port) |
| video-story | video-story.apps.poofc.com | — | 4015 (Vite+Express+SQLite, AI video pipeline) |
| video-story | video-story.apps.poofc.com | — | 4015 (Vite+Express+SQLite, AI film pipeline) |
| hd-prism | hd-prism.apps.poofc.com | 3005 | 4016 (Next.js client + Express/SQLite API, separate processes) |
Use the existing viewer app when Alex wants Hermes responses turned into rich web pages/visualizations with page-specific HTML/CSS/JS rather than a new standalone app.
/home/avalon/apps/viewerfiremountain/viewerhttps://viewer.apps.poofc.com/p/:slugviewer4025/home/avalon/apps/viewer/viewer.sqliteviewer-{slug}/p/:slug and raw page routes) should remain public; root/admin management can keep nginx basic auth.Workflow for a new Viewer page:
1. Create/select a stable slug (kebab-case, descriptive, durable).
2. Create the page record with isolated markup/style/script/data for that page; do NOT force global styling if the page asks for its own visual language.
3. Create/ensure the page bucket viewer-{slug} and upload media assets there.
4. Index asset metadata in SQLite with page_slug, source/provenance, content type, storage key, and public URL.
5. Export a JSON snapshot under exported-pages/{slug}.json so the page is repo-tracked/recoverable.
6. Build/restart PM2, verify the public /p/:slug URL returns 200, verify representative S3 asset URLs return 200, then add the new page as a direct card in the Viewer pages section of /home/avalon/apps/app-launcher/index.html so it is discoverable from launcher.apps.poofc.com.
7. Commit and push both the Viewer repo changes and the launcher repo changes.
For source-atlas pages (manuscript/book/image catalogs), distinguish clearly between seed/reference imagery and direct source scans/crops. Store provenance fields such as source title, edition/manuscript, folio/page, crop notes, and extraction method so future passes can replace or add historical layers without losing the seed layer.
See references/viewer-app-pages.md for the first Viewer page pattern and verification checklist.
For apps built with Vite + React (not Next.js), the deployment is different.
For legacy split frontend/backend apps that need to be refactored into Alex's VPS stack — especially Vite + Firebase client apps with a separate Express/Firebase backend repo, committed node_modules, gitignored config.js, Firebase service-account JSONs, and hardcoded AI models/providers — use references/legacy-firebase-vite-app-vps-refactor.md. The target shape is usually one production Vite+Express app on a single PM2 port, with env-driven frontend config, an OpenAI-compatible model adapter, app-level auth, and no nginx Basic Auth on the PWA shell. If the refactor must live outside the original app repo, also use references/separate-vps-refactor-repo.md to create/cleanly switch to an APP-vps repo and deployed path. For a lightweight “2nd version” refactor where the old app’s assets should be preserved but the code becomes a fresh React/Vite/PWA app, see references/tarot2-react-pwa-refactor.md for the Tarot II example.
For older Vite/Vercel-style repos with a root Vite frontend plus api/server.js Express module, use references/legacy-vite-vercel-api-to-pm2.md: make the Express API serve built dist/ on one port, listen under PM2 in production, configure OpenAI SDK baseURL/headers when using OpenRouter as an OpenAI-compatible provider, verify a real model-backed endpoint, and check spoken domains against DNS before creating certs.
For exploratory project cockpits that read a markdown wiki plus a Hermes Kanban board, use the pattern in references/wiki-kanban-cockpit-pwa.md: keep wiki/Kanban as sources of truth, expose read-only Express APIs, build a mobile-first PWA, verify source-grounding/path traversal, and use bounded cron continuation only when Alex explicitly asks for autonomous work while away.
For reusable in-app Hermes chat/voice/file components, use references/hermes-app-bridge-vps-pattern.md: browser UI -> app-owned /api/hermes bridge -> Hermes /v1/runs, with server-side credentials, app context injection, SSE event proxying, voice/file affordances, Vite local-package dependency pitfalls, and the duplicate-React blank-screen fix (resolve.dedupe: ['react','react-dom']).
Before the first commit in any Vite/React app, create .gitignore with node_modules/, dist/, .env, logs, and OS junk. Never let node_modules or built dist enter the initial commit; if they do, immediately git rm -r --cached node_modules dist, commit the cleanup, and push.
For apps built with Vite + React (not Next.js), the deployment is different:
.next/standalone — Vite builds to dist/ (static HTML/CSS/JS)vite-plugin-api.ts) — must be extracted into a standalone Express server for productionindex.htmlIf the app has its API as a Vite plugin:
1. Read the plugin file — copy ALL route handlers into a standalone server.ts
2. Use Express with cors, compression, express.static(distDir)
3. Add SPA catch-all: app.get('/{*splat}', ...) (NOT '*' — Express 5 requires named wildcards)
4. Connect to DB using env vars (DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME)
Express 5 (installed by default with newer npm) uses path-to-regexp v8+ which does NOT support bare * catch-all routes. Use '/{*splat}' instead:
// WRONG — crashes with "Missing parameter name at index 1"
app.get('*', (req, res) => res.sendFile('index.html'))
// CORRECT — Express 5 named wildcard
app.get('/{*splat}', (req, res) => res.sendFile('index.html'))
If the app needs a Docker database (e.g., Postgres):
- Check if the default port is already in use: ss -tlnp | grep 5432
- If so, remap to a different host port (e.g., 5433:5432)
- Use sudo docker-compose (not docker compose — may not work on all installs)
- DB schema in docker-entrypoint-initdb.d/ runs automatically on first container start
- Seed separately after container is healthy
# 1. Install & build frontend
npm install --legacy-peer-deps # may need legacy flag
npx vite build # outputs to dist/
# 2. Start Docker DB (if needed)
sudo docker-compose -f docker-compose.prod.yml up -d
# 3. Seed database
DATABASE_URL="postgresql://user:pass@localhost:PORT/dbname" npx tsx db/seed.ts
# 4. Start with PM2 (single process — serves both API + static)
DB_PORT=5433 PORT=4010 pm2 start npx --name app-name -- tsx server.ts
pm2 save
# 5. Nginx — single upstream (no separate API/client ports)
# proxy_pass http://127.0.0.1:4010; (everything goes to one port)
server {
listen 443 ssl;
server_name APP.apps.poofc.com;
# ... SSL config ...
auth_basic "Restricted Access";
auth_basic_user_file /etc/nginx/.htpasswd;
# API routes MUST skip basic auth — browser fetch() won't forward
# Basic Auth credentials reliably, causing silent 401s on all API calls
location /api/ {
auth_basic off;
proxy_pass http://127.0.0.1:PORT;
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;
}
location / {
proxy_pass http://127.0.0.1:PORT;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
}
}
| App | Subdomain | Port |
|---|---|---|
| jungle-studio | jungle-studio.apps.poofc.com | 4010 |
| jungle-mobile | jungle-mobile.apps.poofc.com | 4011 |
| todos | todos.apps.poofc.com | 4014 |
| video-story | video-story.apps.poofc.com | 4015 |
| thursday-web | thursday.apps.poofc.com | 4019 |
| transit-list-demo | transit-list-demo.apps.poofc.com | 4021 |
| obsidian-quartz | obsidian.apps.poofc.com | 4022 |
| language-wheels | language-wheels.apps.poofc.com | 4023 |
| hermes-workspace | hermes workspace service | 4024 |
| viewer | viewer.apps.poofc.com | 4025 |
| astral-hermes-web | astral.apps.poofc.com | 4026 |
| hermes-ads | hermes-ads.apps.poofc.com | planned 4027 |
| magi | magi.apps.poofc.com | 4028 (Vite+Express+Postgres/local-S3; launched as firemountain/magi-vps) |
| hermes-social | hermes-social.apps.poofc.com | 4029 |
| hermes-creative | hermes-creative.apps.poofc.com | 4030 |
| hermes-spawn | spawn.apps.poofc.com (live; local 127.0.0.1:4031) | 4031 |
| hermes-trader | hermes-trader.apps.poofc.com (Vite+Express+SQLite, astrology/crypto paper trading cockpit) | 4032 |
| tarot | tarot.apps.poofc.com (legacy alexTarot, Vite/Vercel-style Express static/API server) | 4033 |
| tarot2 | tarot2.apps.poofc.com (React/Vite/Express PWA refactor of alexTarot; see references/tarot2-react-pwa-refactor.md) |
4034 |
Hermes Trader quick reference:
- Path: /home/avalon/apps/hermes-trader
- Repo: firemountain/hermes-trader
- PM2: hermes-trader
- Port: 4032
- URL: https://hermes-trader.apps.poofc.com
- Current state: Vite+React/Express + SQLite dashboard for paper-only astrology-informed crypto swap research. It has app-level developer auth, seeded strategy definitions, experiment creation, blind schedule sealing with deterministic SHA-256 hashes, paper-run reveal/alpha accounting, confidence/live-usability metrics, executable research-task queue, /leaderboard result ranking with sober one-sentence evidence summaries and data-centric grades, /monitor queue/runtime visibility, research-cycle auditing, and a cron snapshot viewer.
- Auth env: live password hash is HERMES_TRADER_PASSWORD_HASH in .env; raw local mirror is /home/avalon/.hermes/secrets/hermes-trader-password.txt (0600).
- Project-specific skills live inside the repo under skills/hermes-trader-*; keep them project-local rather than adding narrow global skills. Current bundle includes blind backtesting, astrology strategy lab, and ongoing research.
- Hermes cron awareness uses ~/.hermes/scripts/hermes-trader-cron-snapshot.py via no-agent cron job Hermes Trader cron awareness snapshot; cronjob script paths must be relative to ~/.hermes/scripts/.
- Ongoing research loop: agent cron Hermes Trader autonomous research worker (every 10m, local), no-agent script cron Hermes Trader deterministic cycle recorder (every 10m, runs hermes-trader-ongoing-research.sh -> seeds executable full-history/finer-granularity research tasks and invokes scripts/execute-research-tasks.mjs), no-agent Hermes Trader cron awareness snapshot (every 10m), and origin reporter Hermes Trader daily research digest. For BTC full-history research, default to public/free Bitstamp BTC/USD OHLC (MARKET_DATA_SOURCE=bitstamp, verified from 2011-08-18T13:00:00Z) rather than Coinbase-only history; keep Coinbase as a comparison source.
- See references/hermes-trader-strategy-lab.md for the API/schema/cron/TDD verification pattern and the self-improving research-cycle architecture.
Magi debugging note: if birth submit hides the form and then shows a blank page, check for a client exception after #inputContainer is hidden and verify the chart provider. One known root cause was config.js exporting planets as an array while dataFetcher.js called .split(" "); another was astrodataserver.com TLS expiry. Normalize planet values, add submit rollback UI, and use/fallback to transit-list-demo for chart JSON/SVG. If Mercury/Venus/Mars are missing, do not rewrite the original SVG astrology-font entities; preserve the original SVG and append Unicode overlays. Prefer original body-glyph x/y coordinates when the <g class="mercury ..." data-longitude="..."> body group exists, and only calculate from JSON longitude when the body marker is absent. Ensure detection does not count aspect groups as planet markers. If interpretation sections duplicate, verify the SSE endpoint emits one title/end pair per selected planet, then check frontend lifecycle: stale EventSource, pending delayed summary timeout, stale async fetchSystemMessage/setup race, double-tap submit, every terminal stream path closing its EventSource, and HandyCollapse reinitialization. If the chart appears but the reading area is blank, inspect /api/getSummary2 SSE output and LLM provider status; OpenRouter needs OpenAI-compatible chat-completions format plus HTTP-Referer/X-Title headers. If aspect lists/SPG previews/slide-out aspect details disappear in the refactor, check the provider aspect flag translation first (jsonincludeaspects -> aspects=true for transit-list-demo) and restore the same-origin chart-data + client drawer pattern in references/magi-vps-aspect-previews.md. If registration succeeds but the modal stays open, check for overwritten global window.closeModal and treat chart-save failure as non-fatal after account creation. See references/magi-vps-chart-submit-debugging.md and references/magi-vps-aspect-previews.md.
For hermes-workspace knowledge-browser work, treat upstream outsourc-e/hermes-workspace as the base project and Alex's fork firemountain/hermes-workspace as the deploy fork. Before editing, fetch/rebase latest upstream, stash local work if dirty, then reapply changes so Workspace does not drift from the project:
cd /home/avalon/apps/hermes-workspace
git fetch --all --prune
git stash push -u -m "pre-knowledge-work-$(date +%Y%m%d-%H%M%S)" # if dirty
git rebase origin/main
Knowledge-base discovery should be Hermes-native and profile-safe where possible:
- Prefer ~/.hermes/knowledge-config.json, ~/.hermes/knowledge/, and ~/.hermes/knowledge-cache/.
- Keep ~/.claude/knowledge only as a legacy fallback, not the primary path.
- For Alex's current HD wiki, config points to /home/avalon/wiki-human-design; the ingestion swarm state is under /home/avalon/hd-wiki-swarm.
- If the Knowledge tab is empty, first verify the config file and the server module rather than assuming the wiki is missing.
Useful verification pattern for Workspace knowledge changes:
pnpm build
node --import tsx /tmp/check-workspace-knowledge.mjs # or an equivalent script importing src/server modules
pm2 restart hermes-workspace --update-env
pm2 save
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4024/
curl -k --resolve hermes.apps.poofc.com:443:127.0.0.1 -o /dev/null -w '%{http_code}\n' https://hermes.apps.poofc.com/
For Hermes Workspace as an installable PWA, public root should ultimately be 200 without nginx Basic Auth; if it is still 401, standalone iOS/Android launches can fail before app-level auth loads. Local 127.0.0.1:4024 should return 200. App/API routes are app-auth protected, so direct unauthenticated curls to /api/* may return 401; verify protected behavior with a valid hermes-workspace-auth cookie from ~/.hermes/workspace-sessions.json or by importing server modules with node --import tsx when no session cookie is available. See software-development/pwa-configuration reference references/hermes-workspace-pwa.md for the current PWA/auth/nginx workflow.
Lint caveat: full-repo pnpm lint in hermes-workspace can OOM or fail on large pre-existing upstream lint debt. Use NODE_OPTIONS='--max-old-space-size=4096' and run targeted ESLint on changed files. Do not block deploy on unrelated repo-wide lint failures when pnpm build and targeted changed-file lint pass.
See references/hermes-workspace-knowledge-integration.md for the session-specific multi-base Knowledge tab workflow, verification script pattern, and current HD wiki baseline.
| astral-hermes-web | astral.apps.poofc.com | 4026 |
| hermes-ads | hermes-ads.apps.poofc.com | planned 4027 |
Hermes Ads quick reference:
- Path: /home/avalon/apps/hermes-ads
- Repo: firemountain/hermes-ads (private)
- PM2: hermes-ads
- Port: 4027
- URL: https://hermes-ads.apps.poofc.com
- Current state: Vite+React/Express + SQLite dashboard MVP. It serves a mobile-first Meta ads cockpit with Command, Account, Reports, Drafts, Setup, and Audit tabs; read-only Meta sync/reporting works for Astro Mage; local drafts/approvals/audit events exist; Meta writes are still intentionally disabled until ads_management and explicit approved paused writes.
- Local DB: /home/avalon/apps/hermes-ads/data/hermes-ads.sqlite (uncommitted; .gitignore ignores data/ and SQLite WAL/SHM files).
- For architecture/build guidance, load the paid-ads-agent-platforms skill, especially references/hermes-ads-dashboard-mvp.md.
Astral Hermes quick reference:
- Path: /home/avalon/apps/astral-hermes-platform
- Repo: firemountain/astral-hermes-platform
- Public URL: https://astral.apps.poofc.com
- Public root /: strict minimal mock chat/knowledge-console landing page. Use the original soft light-gray Astral palette, not the later parchment/reference-image treatment. No big titles/subtitles/filter chips/decorative orbits. Mobile-first: hidden side panel, almost blank chat, one animated prompt, bottom input. Empty input shows a working mic recorder with a proper SVG microphone icon; typed input swaps to send; text/audio messages post into chat and trigger randomized mock replies. Voice note playback should be custom and Telegram-like in layout only — large circular play/pause, waveform bars, duration/status, small ••• control — but never Telegram green/blue; use Astral's own warm ink/espresso, muted bronze/gold, and soft gray palette. Avoid native browser audio controls, volume/mute buttons, or bulky audio chrome. Chat composer UX: it should behave like a normal multiline message box — Return/Enter inserts a newline, only the visible send button/form submit sends, multiline bubbles render with white-space: pre-wrap, and the textarea auto-grows to a small max height before internal scrolling. For exact code checkpoints and a Playwright --no-sandbox verification recipe, see references/astral-composer-multiline-verification.md. For chat interaction polish, prefer very subtle Telegram-quality micro-animations: 140–220ms control feedback, 300–420ms message/overlay transitions, tiny rise/scale/blur fades, typing dots during delayed mock replies, input focus lift, tactile press compression, waveform bar entrance/progress transitions, eased sidebar slide + scrim fade, all respecting prefers-reduced-motion. The actual invite-code provisioning wizard lives at /onboarding; keep smoke/provision tests pointed at /onboarding.
- Admin URL: https://astral.apps.poofc.com/admin (PWA-safe public app shell; admin API access is protected by app-level bearer token stored in the client, not nginx Basic Auth. /api/admin/* must require the token and must never return raw tenant secrets.)
- PM2: astral-hermes-web
- Port: 4026
- Worker node: astral-node-1 (5.78.199.26), runtime root /srv/astral
- Purpose: invite-code guarded onboarding/control-plane MVP that provisions Dockerized tenant Hermes homes/containers on the worker node.
- Admin console exposes tenant status, local skill inventory/inspector, planned skill profiles, and worker bundle listing. Admin APIs must return only secret variable names such as OPENAI_API_KEY/TELEGRAM_BOT_TOKEN, never values from tenant .env.
- Admin tenant counts should distinguish real tenants from benchmark/test directories: benchmark dirs like bench-* are real filesystem directories but should not inflate headline customer/real tenant metrics.
- Astral UI should be mobile-first, compact, minimal light-gray, and Typeform-inspired for onboarding. On mobile, keep admin skill/profile cards tight, avoid oversized padding, and ensure <a> elements styled as buttons use flex centering (display:inline-flex; align-items:center; justify-content:center) so text is visually centered.
- Astral public chat landing preference: preserve the original pre-reference-image palette from around commit 0046acf (#eeeeeb/soft light-gray gradient, translucent white panels, muted gray/gold accents). Keep the chat landing strictly minimal: no big titles, subtitles, unnecessary section headers, chat-message blocks, decorative orbits, or filter chips. Mobile-first means the knowledge/sidebar panel is hidden completely behind a hamburger/scrim, the page is almost blank, one small animated prompt appears near the top of the chat, and the input sits directly at the bottom of the screen rather than nested inside a card. Desktop can use a simple ChatGPT-like left sidebar plus blank main chat and bottom-centered input.
- Current skill rollout state: bin/astral.mjs supports bundle create/publish/install/status; production tenants should have astral-core@0.2.0 plus astral-hd@0.2.0. astral-core includes astral-chart-api so tenants call transit-list-demo for astrology chart calculation rather than falling back to vague/non-tool responses. astral-hd includes hd-prism-tenant-api, which calls shared HD Prism POST https://hdprism.apps.poofc.com/api/tenant/bodygraph instead of tenant-local HD dependencies. Scoped tenant containers must set HERMES_BUNDLED_SKILLS=/data/hermes/.astral/no-bundled-skills or Hermes will auto-sync the full bundled skill catalog back into $HERMES_HOME/skills on startup. For openai-codex tenants, voice transcription needs a control-plane fallback key and chart/HD skills need a real service-call path (usually terminal enabled); see references/astral-hermes-platform.md “Voice transcription and tenant API-call capability”.
- PWA/control-plane admin pattern: keep installable app-shell routes public at nginx and protect /api/admin/* with app-level bearer/session auth; see references/pwa-control-plane-admin.md.
- Onboarding provisioning UX should expose durable progress on the submit/review step, not just a momentary disabled button. For Astral, verify /api/provision directly with a disposable tenant before assuming backend failure; clean up remote tenant/container and entitlement ledger rows afterward. Return telegramBotUsername/telegramLink so success can show an Open Telegram bot chat button. See references/astral-hermes-platform.md “Onboarding provisioning UX and debugging”.
- BYO OpenAI API key provisioning must generate Hermes config as provider: custom, base_url: https://api.openai.com/v1, api_mode: chat_completions, api_key: ${OPENAI_API_KEY}. Do not use provider: openai (Hermes may reject it) or openai-codex (OAuth/subscription flow, not raw API key). For subscription-first onboarding, default to openai-codex / ChatGPT subscription, skip raw API-key collection, then complete device authorization from the Astral control plane and write the OAuth credential into tenant auth.json; see references/astral-onboarding-subscription-provider.md and references/hermes-spawn-control-plane.md. Verify inside the tenant container with hermes chat -q 'Reply with exactly: ok' -Q after restarting the container.
- Session-specific details, endpoints, bundle rollout, Stripe hosted-beta billing, customer-onboarding Checkout flow, tenant knowledge-base/transit scaffolding, and the current web-chat-to-tenant-Hermes bridge/readiness gates: references/astral-hermes-platform.md. For the live MVP bridge, /chat is protected by either a logged-in customer session that owns the requested tenant or the operator ASTRAL_CHAT_TOKEN; /api/chat/* uses the Astral backend to SSH/docker-exec into tenant containers with safe base64 prompt transport and Hermes session-id parsing; root / remains a mock demo.
- Astral customer auth pattern: expose /account as the customer login/registration dashboard, keep registration invite-code gated, hash passwords server-side (current implementation uses PBKDF2), store sessions in signed HttpOnly cookies (ASTRAL_SESSION_SECRET must exist in .env), and never return passwordHash from account APIs/admin entitlements. Onboarding should avoid double sign-up: call the optional /api/auth/session endpoint, reuse an existing logged-in account, and skip the account/password step when a session exists; only collect owner email + account password when there is no session. Provisioning should set the account password after provisioning, set the session cookie, and link success to /account and /chat?tenant=.... Customer chat/status endpoints must authorize tenant ownership from the entitlement ledger; keep operator token support as an override only for admin/testing. Regression tests should cover safe account serialization, bad login rejection, owned-tenant access, and cross-tenant denial. For the current onboarding/subscription-provider shape and verification checklist, see references/astral-onboarding-subscription-provider.md.
- Astral live web chat UX: chat is the primary surface, especially on mobile. Do not stack account/tenant/token/status controls above the chat on small screens; put those secondary controls in a proper left slide-out drawer behind a hamburger + scrim + close button, leaving the chat and bottom composer visible immediately. Desktop may keep a left sidebar, but make it wide enough for cards/emails and not a crushed strip. If the user is signed in, do not keep public signup/onboarding CTAs in the chat chrome. Put logged-in identity, tenant connect controls, tenant online/status, and logout inside a collapsed Settings group in the side panel; keep the primary chat action (New thread) in the top-right chat header. If using <details> for Settings, visually verify that closed Settings contents have zero visible layout/rect area (add an explicit .settings-section:not([open]) .settings-stack { display: none; } guard if custom CSS causes hidden children to reserve space). If the chat shell probes auth on load, use an optional session endpoint that returns { account:null, tenants:[] } when logged out so public /chat smoke tests do not emit expected-but-noisy 401 console errors; keep strict /api/auth/me protected for security regression coverage.
For games, demos, or simple frontends that don't need SSR/API:
app-name/
├── server.js # Express serving static files from project root
├── index.html # Entry point (may use ES modules)
├── src/ # JS source files
├── public/ # Assets (images, sounds)
├── package.json
├── README.md
└── .gitignore
Skip the full client/server split, LogPanel, and Socket.IO — use a single Express server. Reserve the full stack for CRUD apps.
When serving static sites that use ES modules (<script type="module">), the catch-all route MUST NOT serve index.html for .js/.css/asset requests — browsers enforce strict MIME type checking for module scripts and will reject text/html responses.
const express = require('express');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 4012;
// Serve static files FIRST with correct MIME types
app.use(express.static(path.join(__dirname), {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.js') || filePath.endsWith('.mjs')) {
res.setHeader('Content-Type', 'application/javascript');
}
}
}));
// Catch-all: only serve index.html for non-file requests
app.get('/{*splat}', (req, res) => {
if (path.extname(req.path)) {
return res.status(404).send('Not found');
}
res.sendFile(path.join(__dirname, 'index.html'));
});
app.listen(PORT, '::', () => console.log(`Running on port ${PORT}`));
# 1. Clone to /home/avalon/apps/app-name
git clone https://github.com/user/repo /home/avalon/apps/app-name
cd /home/avalon/apps/app-name
# 2. Add server.js (template above), npm init -y, npm install express
# 3. Start with PM2
pm2 start server.js --name app-name
pm2 save
astral-node-1), terminal logs will show wrappers like ssh avalon@5.78.199.26 'docker run ...'. Before running a batch of remote commands, say clearly that commands are being executed on the remote node via SSH, and in summaries present them conceptually as # on astral-node-1: docker ... rather than making the user mentally parse SSH wrappers. This avoids confusion about why we are "SSHing into our own VPS".firemountain/APP-vps (or the requested name), push the VPS refactor there as main, set the deployment working tree origin to the new repo, keep the original remote only as original/upstream if needed, delete any accidental VPS refactor branch from the original remote, restore the original checkout to its normal branch, and clone/move the deployed PM2 cwd to /home/avalon/apps/APP-vps. Then search for hardcoded old paths (/home/avalon/apps/APP, storage roots, ecosystem cwd, docs, nginx snippets), update them, rebuild, restart PM2 with the new --cwd, verify pm2 show APP script/cwd, healthcheck, smoke tests, and push. Do not leave the live PM2 process running from the original repo path after repo separation.gh is authenticated: If git push over SSH fails with Host key verification failed or Permission denied (publickey), do not keep retrying SSH or embed a PAT in the remote. First add/refresh github.com in ~/.ssh/known_hosts if needed, then run gh auth status. If gh is logged in with repo scope, use gh auth setup-git, temporarily set the remote to https://github.com/OWNER/REPO.git, push, then restore the normal SSH remote: git remote set-url origin git@github.com:OWNER/REPO.git. This uses the gh credential helper without leaving tokens in git config.gh CLI may be absent and the old OpenClaw GitHub token may be stale: If gh repo create fails with gh: command not found, first try GitHub REST API with a current token from ~/.config/gh/hosts.yml (oauth_token:). ~/.openclaw/credentials/github can exist but return 401 Bad credentials. Create the repo via POST https://api.github.com/user/repos, push using a temporary askpass/HTTPS credential helper, and immediately reset origin to git@github.com:OWNER/REPO.git so no token is left in .git/config. Do not leave https://TOKEN@github.com/... in the remote.*.apps.poofc.com may already resolve to the main VPS, so a new subdomain like astral.apps.poofc.com often needs only nginx+certbot, not DNS work. Verify with dig +short A astral.apps.poofc.com before asking Alex to change DNS. Keep API routes unauthenticated at nginx (location /api/) only if the app itself gates sensitive actions (e.g. invite code, billing/auth); do not expose provisioning APIs without an app-level guard.apps.poofc.com / app index / app launcher, do not rely on the existing HTML list or memory. Build the inventory from live sources: (1) app directories with find /home/avalon/apps -maxdepth 1 -mindepth 1 -type d, (2) active PM2 processes with a concise pm2 jlist parser showing name/status/cwd/script/args/PORT, and (3) nginx enabled hostnames/proxies from /etc/nginx/sites-enabled and /etc/nginx/sites-available/apps.poofc.com. Probe nginx routes locally using curl --resolve HOST:443:127.0.0.1 -u admin:SecurePass123! for HTTPS routes and --resolve HOST:80:127.0.0.1 for HTTP-only roots. Include routes that return 502/401/other non-200 as visible cards with an issue/auth badge so the launcher remains a complete map of configured apps.*.apps.poofc.com wildcard DNS does not necessarily cover the bare apps.poofc.com hostname. If certbot fails for apps.poofc.com with no valid A records found, the fix is DNS, not nginx. Keep local verification grounded with --resolve until DNS exists.jlist output can be huge and leak env noise: For inventory tasks, never paste raw pm2 jlist; parse it with Python/jq to extract only pm_id, name, status, pm_cwd, pm_exec_path, args, and PORT.pm2 list may show app names but miss the practical conflict if another service already binds a candidate port (example: hermes-workspace binds 127.0.0.1:4024). Before selecting a port, run ss -tlnp | grep -E ':(PORT|PORT+1|...)' and pick a truly free port. If a local curl returns the wrong app's HTML (for example Hermes Workspace on /api/health), check ss -tlnp immediately rather than debugging your new server.--ignore-scripts needs rebuild: If npm install --ignore-scripts was used for safety, native modules like better-sqlite3 may install without a compiled binding and crash at runtime with missing better_sqlite3.node. Run npm rebuild better-sqlite3 before starting PM2.workflow token scope: If pushing a repo/fork that includes .github/workflows/*, GitHub rejects PAT pushes without the workflow scope: refusing to allow a Personal Access Token to create or update workflow ... without workflow scope. For Alex's deployment repos, prefer removing upstream workflow files from the committed deployment tree unless those Actions are intentionally needed. Then push normally. Do not waste time retrying force pushes; the token scope is the root cause.curl can hang from the VPS even when nginx/local service is healthy: If curl https://APP.apps.poofc.com/ hangs but local port checks pass, verify nginx locally with a direct TLS socket to 127.0.0.1:443 using the target hostname as SNI and Host: header. This distinguishes nginx/app health from network/DNS/hairpin issues and avoids repeated blocked/timeouting curl attempts.pm2 start /path/to/server.js from a different directory, PM2 sets exec cwd to your current directory, NOT the app directory. This breaks dotenv/config and any relative path resolution. ALWAYS use --cwd: pm2 start /home/avalon/apps/APP/server/index.js --name APP --cwd /home/avalon/apps/APP. Verify with pm2 show APP | grep "exec cwd".getPasswordHash()/auth config first and update the actual env var it reads, not a generic guess like DEVELOPER_PASSWORD_SHA256. For Hermes Trader, the live variable is HERMES_TRADER_PASSWORD_HASH in /home/avalon/apps/hermes-trader/.env, while the raw developer password mirror is /home/avalon/.hermes/secrets/hermes-trader-password.txt (0600). After editing, restart with pm2 restart APP --update-env, wait briefly for the port to rebind, then verify both /api/health and a real login + authenticated API call..env under PM2: If a deployed Node server depends on .env but was started by PM2 without explicit env or correct cwd, endpoints can behave as if secrets are missing (Astral admin returned Admin token is not configured). Either restart with --cwd and env prefix/update-env, or make server.mjs load the project .env at boot before reading process.env. Re-verify protected endpoints unauthenticated (401) and authenticated (200).node server/index.js & before PM2, the process survives and holds the port even after pm2 stop. Always check ss -tlnp | grep PORT and kill orphans before PM2 start.baseURL — OpenRouter uses OpenAI chat completions format (/v1/chat/completions), not the Anthropic messages API. Use fetch directly for OpenRouter with { model: 'anthropic/MODEL', messages: [...] } and normalize the response: { content: [{ type: 'text', text: data.choices[0].message.content }] } to match Anthropic's shape.Stage.js imported but file is stage.js, Ken.png referenced but file is ken.png). Linux is case-sensitive so these 404. After cloning, audit imports vs actual filenames: find . -name '*.js' | sort and cross-reference with grep -rn "from.*\.js" src/. Rename files to match what the code expects.<script type="module"> and Express has a catch-all app.get('/{*splat}', ...) serving index.html, the catch-all will serve HTML for .js file requests, causing Failed to load module script: MIME type "text/html" errors. Fix: check path.extname(req.path) before the catch-all and return 404 for file extensions:app.get('/{*splat}', (req, res, next) => {
if (path.extname(req.path)) {
return res.status(404).send('Not found');
}
res.sendFile(path.join(__dirname, 'index.html'));
});
'::' not '0.0.0.0' for IPv6 compatsudo -n true. If it says a password is required, proceed only if Alex explicitly provides the sudo password in-session. Prefer a PTY/background process (sudo -S ...) and submit the password via the process tool; if only non-interactive terminal execution is available, a one-shot printf '%s\n' "$SUDO_PASS" | sudo -S bash -lc '...' is acceptable for immediate root-only edits, but never write the password to files, scripts, git, memory, or summaries. If no password is provided, make all non-root app/PM2 changes you can, then report the exact root-only change still needed. Do not claim nginx edits, certbot, or Basic Auth changes succeeded unless nginx -t, reload, and route/cert verification confirm it. For new apps, also create a ready-to-copy nginx config under /tmp, update the launcher as issue/nginx pending, and include the exact root commands needed; see references/no-sudo-new-app-deployment.md.pm2 jlist plus ss -tlnp. Do not rely only on ss process names because they can be truncated (e.g. node /home/aval), and do not run pm2 inside sudo bash unless you know root's PATH/environment has PM2. Pattern: run pm2 jlist > /tmp/pm2-app.json as avalon, parse name/status/PORT/pm_exec_path/pm_cwd, verify local /api/health identity on 127.0.0.1:PORT, then use sudo only for /etc/nginx, certbot, nginx -t, and reload. If the port is not owned by the intended app, stop and pick a free port rather than repointing nginx.ConnectionResetError. Retry after sleep 3 — it usually works on second tryhttp2 on; may be unsupported even on nginx 1.24: On Alex's current Ubuntu nginx, adding a standalone http2 on; directive caused unknown directive "http2" and blocked reload. For new app templates, prefer existing working style (listen 443 ssl; / listen [::]:443 ssl;) unless you have verified this nginx build supports the directive. If a template fails at nginx -t, patch the repo template and live site before reloading.sudo cp to /etc/nginx/sites-available/client_max_body_size 200M; in nginx config for apps handling video/image uploads'/{*splat}' not '*' — Express 5 requires named wildcards in path-to-regexp v8+"type": "module" breaks CommonJS servers: Vite scaffolding adds "type": "module" to package.json, which makes Node treat ALL .js files as ESM — including your Express server using require(). Fix: name server files with .cjs extension (e.g., server.cjs) so Node treats them as CommonJS regardless of package.json. Alternative: rewrite server to use import/export syntax, but .cjs is the fastest fix.--legacy-peer-deps flagnpm install even when the app is already buildable: If git pull only changes app source/assets and leaves package.json/lockfile unchanged, npm install can still fail on peer-dependency re-resolution (example: vite@8 vs @tailwindcss/vite@4.2.1 wanting vite ^5.2 || ^6 || ^7). Before forcing a reinstall, check whether package.json or the lockfile actually changed in the pulled commit. If they did NOT change, skip reinstall, run the build with the existing node_modules, restart PM2, and verify health. Only use npm install --legacy-peer-deps (or otherwise touch deps) when dependency files changed or modules are actually missing.npm install can dirty package-lock.json during deploy without real dependency changes: Newer npm versions may remove "peer": true metadata or otherwise rewrite lockfile formatting after a pull. If npm install succeeds but git status shows only lockfile metadata churn, inspect git diff -- package-lock.json; if it is non-functional lockfile noise, git checkout -- package-lock.json, rerun npm run build, restart PM2, and verify. Do not leave production repos dirty after a deploy unless intentionally committing/pushing a code change.sudo docker-compose (hyphenated) if docker compose (space) fails with "unknown shorthand flag"cd to the correct directory firstpm2 start /path/to/server.js --name app inherits the cwd of your CURRENT shell, not the app directory. If you start from /home/avalon/.hermes/hermes-agent, dotenv/config loads .env from THERE (not found). ALWAYS use --cwd: pm2 start /path/to/server.js --name app --cwd /path/to/app. Verify: pm2 show app | grep "exec cwd". This caused auth failures in Video Story where all API keys returned undefined.pm2 start, not appended after --: pm2 start server.cjs --name app -- PORT=4018 passes PORT=4018 as a script argument, so the app still uses its default port. Correct: PORT=4018 pm2 start /path/server.cjs --name app --cwd /path. Verify with pm2 show app (script args should be N/A) and curl the expected port before nginx/certbot.mv: After moving an app (for example from /home/avalon/APP to /home/avalon/apps/APP), inspect and update all live path references before declaring success. Minimum checklist: (1) pm2 show APP to inspect script path and exec cwd; if stale, delete/recreate or restart with explicit --cwd, then pm2 save; (2) search the codebase and nearby tooling for hardcoded old paths (shared upload dirs, helper scripts, local agent settings, etc.); (3) inspect nginx config to see whether it uses filesystem paths or only upstream ports — if it only proxies to 127.0.0.1:PORT, no nginx path change is needed; (4) restart dependent apps that reference the moved app's files (for Jungle, jungle-mobile had to be restarted because it served uploads from the admin app's public/uploads directory); (5) verify both localhost and public URLs after the move. Also check .pm2/dump.pm2 after pm2 save; stale paths in .bak are harmless, but the active dump must point to the new directory.runpod-serverless skill. Express app on VPS serves as frontend + file host, RunPod handles GPU processing<script type="module"> will FAIL if Express catch-all serves index.html for .js requests — browser rejects text/html as module MIME type. Always check path.extname(req.path) in catch-all and return 404 for file requests. See "Static App Server Template" sectionimport from './constants/Stage.js' but file is stage.js, or src="images/ken.png" but file is Ken.png). After cloning, audit imports vs actual filenames: find src -name '*.js' -exec grep -oP "from\s+['\"][^'\"]+['\"]" {} \; then cross-check with find . -type f. Rename files to match imports (not the other way around — fewer changes)npm create vite@latest . (current dir) triggers an interactive prompt that auto-cancels. Use npm create vite@latest temp-dir --template react-ts to scaffold into a temp directory, then cp -r temp-dir/* . and remove the temp dirnpm create vite@latest adds "type": "module" to package.json, which makes ALL .js files ESM. If your Express server uses require() (CommonJS), it crashes with ReferenceError: require is not defined in ES module scope. Fix: name the server file server.cjs (not .js). This applies to PM2 start commands too: pm2 start server.cjs --name app. Don't waste time converting to ESM imports — .cjs is the simplest fix.npm create vite@latest --template react (Vite 8+) creates a package.json that does NOT include react, react-dom, or @vitejs/plugin-react as dependencies. The initial npm install only installs vite itself. You MUST manually install them: npm install react react-dom @vitejs/plugin-react before building, or the build fails with ERR_MODULE_NOT_FOUND for @vitejs/plugin-react and "failed to resolve import react". This is a change from earlier Vite versions where the template included these deps.vite preview behind nginx, requests may return 403 Blocked request. This host ("APP.apps.poofc.com") is not allowed. even though localhost works. Fix either side: (A) add preview: { allowedHosts: ['APP.apps.poofc.com'] } in vite.config.ts, or (B) in nginx for the / location proxying to Vite preview, override the upstream Host header with localhost/IP (for example proxy_set_header Host 127.0.0.1;) so preview accepts the request. Keep the original host in the other forwarded headers if needed.browser_navigate may fail before loading Chrome with No usable sandbox! ... try --no-sandbox. Do not block deployment or keep retrying the browser tool. Fall back to grounded non-browser checks: curl the public root and /api/health, inspect generated dist/assets/index-*.js for expected strings, verify JS MIME with curl -I, check PM2 logs, and report that browser-based visual QA was unavailable due to sandbox. If an actual screenshot is required, use a terminal-launched Chromium with --no-sandbox only if the available tools support passing that flag./etc/nginx/sites-available/apps.poofc.com using map blocks (maps hostname → port, API port, static dir). BEFORE creating a new separate site config, ALWAYS check if the subdomain is already handled by the wildcard config: grep -n "APP_NAME" /etc/nginx/sites-available/apps.poofc.com. If it exists there, update the port in the map block with sudo sed -i — do NOT create a duplicate separate config file (causes conflicting server name and potential 502). If the subdomain is NOT in the wildcard, only then create a separate config. Also check map defaults: an unmatched hostname can still return 200 through default client/API ports or an existing default TLS server, but it may be the WRONG app and WRONG certificate. Do not verify with status code alone. Check content/app identity and certificate SAN:
bash
curl -k --resolve APP.apps.poofc.com:443:127.0.0.1 https://APP.apps.poofc.com/api/health
openssl s_client -connect APP.apps.poofc.com:443 -servername APP.apps.poofc.com </dev/null 2>/dev/null | openssl x509 -noout -subject -ext subjectAltName
Symptom: / loads but React stays on Loading..., /api/health returns another app's JSON, or public curl fails with SSL: no alternative certificate subject name matches. Proper fix is explicit nginx map/server entry + cert. If sudo is unavailable and a user needs an immediate hotfix, and ports are free, temporarily run the app shell on the wildcard client default and a second same server process on the wildcard API default (e.g. PM2 APP on 4000 and APP-api on 3000), then pm2 save; clearly label this as temporary because it can conflict with future default-route apps./home/avalon/APP to /home/avalon/apps/APP), first inspect runtime references with pm2 show APP, pm2 env ID, repo-wide search for the old path, and nginx config search. nginx often only proxies by port, so it may need NO change at all. PM2 DOES need to be recreated or restarted with the new --cwd/script path, then pm2 save so dump.pm2 is updated. Also search sibling apps for hardcoded shared paths (e.g. mobile app serving uploads from admin app’s public/uploads). After the move, verify with both local and public curls: app root, key API endpoint, and any shared static asset paths.sudo cp to sites-available is blocked but sudo ln -sf to sites-enabled succeeds, nginx gets a dangling symlink → open() failed (2: No such file or directory) → nginx reload FAILS → ALL apps go 502. Always ensure the file exists in sites-available BEFORE creating the symlink. And ALWAYS run sudo nginx -t BEFORE sudo systemctl reload nginx — never chain them with && in a single command where earlier steps might fail silently.systemctl --user service mutations in Telegram/gateway mode: Attempts to stop/disable user services such as old noVNC/Obsidian desktop services can time out or be blocked. Do not keep retrying the same blocked command. First verify whether nginx/PM2 routing has already removed public exposure, then report any remaining background services and give exact manual commands only if they still matter.noVNC -> Linux desktop, prefer replacing nginx upstream with a normal static/web app port instead of trying to polish VNC. Use sudo sed -i on the existing nginx site file when gateway security blocks full file writes; then sudo nginx -t, sudo systemctl reload nginx, verify authenticated public 200 and unauthenticated 401 if basic auth remains.systemctl --user list-units --all --type=service | grep -Ei 'obsidian|vnc|xvfb|novnc|websockify', ps -eo pid,ppid,cmd | grep -Ei 'obsidian|xvfb|x11vnc|novnc|websockify', and ss -ltnp | grep -E ':(5988|6088)'. Stop/disable user services, remove their files from ~/.config/systemd/user/, run systemctl --user daemon-reload and reset-failed, delete the obsolete app bundle only after confirming it is not the vault/source-of-truth, and verify /vnc.html returns 404 while the new public route returns 200.quartz build --serve --watch as the long-running public server if a static build is enough; it can invoke watch/websocket behavior and caused localhost curls to hang during the Obsidian route replacement. Build Quartz to public/, serve with a tiny static Node server/Express/static file server on a fixed PM2 port, and run a separate hash-based sync/build loop only when source vault files change.workflow scope, GitHub rejects pushes that create/update .github/workflows/*. Create a clean deployment branch/repo excluding .github/workflows (or all .github) rather than trying to change token scopes mid-deploy. Also exclude generated content/, public/, node_modules, and local sync state from the deploy repo unless intentionally publishing those artifacts.location = / proxy oddity: Replacing a redirect inside an exact location = / with proxy_pass .../ may still show confusing curl 302 output if the test command only prints %{http_code} and follows prior cached/redirect behavior. Re-check with curl -D - and compare / plus /index.html; trust headers/body once they show HTTP/2 200 and the expected HTML title.rm -r, rm -rf, find -delete all require user approval. Use cd dir && rm file1 file2 for individual files, or ask user to approveauth_basic from its nginx config entirely, including route-level app-shell entries such as /admin; launching an installed PWA directly into /admin can otherwise fail before the app loads. Use app-level authentication instead for sensitive screens/APIs. A good control-plane pattern is: public installable app shell, token/password/PIN entry in the React UI, and server-side bearer/session checks on /api/admin/*. Verify both unauthenticated API requests return 401 and authenticated requests return 200.index.html (example: Google Maps JS), use Vite HTML replacement placeholders such as %VITE_GOOGLE_MAPS_API_KEY% instead of hardcoded keys. Put the actual value only in local .env (VITE_GOOGLE_MAPS_API_KEY=...) and verify .env is untracked before committing. For services that also need server-side Google APIs, keep separate backend env names such as GOOGLE_API_KEY / GOOGLE_PLACES_API_KEY; restart PM2 with --update-env after editing .env. A 200 from the Maps JS URL is not enough: check the script body for error markers like RefererNotAllowedMapError, ApiTargetBlockedMapError, and InvalidKeyMapError, and separately smoke-test backend geocode/timezone endpoints with a real city.public/manifest.json with "display": "standalone", icons, theme_color matching the app bg. (2) Create public/sw.js service worker for offline caching (skip /api/ requests). (3) Add to index.html: <meta name="apple-mobile-web-app-capable" content="yes">, <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">, <link rel="manifest" href="/manifest.json">, <link rel="apple-touch-icon" href="/icons/icon-192.png">, and a script to register the SW. (4) In server.ts, serve public/ directory with express.static(publicPath) BEFORE the dist/ directory. (5) Generate 192x192 and 512x512 PNG icons in public/icons/.auth_basic is set at the server level in nginx, the HTML page loads fine (browser prompts for credentials), but JavaScript fetch() calls to /api/ routes silently get 401'd. The browser does NOT reliably forward Basic Auth credentials on fetch() even for same-origin requests. Symptoms: page loads, all data shows zeros/empty, no console errors (fetch failures are caught and swallowed). Fix: add a separate location /api/ { auth_basic off; ... } block BEFORE the catch-all location /. This was discovered on jungle-studio where ALL API calls were failing silently. Always add auth_basic off for /api/ routes when using nginx basic auth on Vite/React SPAs./api/locations/:id/kpis or /api/locations/:id/classes/today), SPA catch-all routes return index.html as 200 OK instead of 404 — the frontend silently fails trying to parse HTML as JSON. Always verify new frontend components have matching backend endpoints. Test with curl before deploying.jungle-studio-dashboard_db_1). The mobile app shares this same DBtsc -b && vite build silently stales dist/: If tsconfig has noUnusedLocals: true or strict: true, pre-existing TS warnings/errors in unrelated files cause tsc -b to exit code 1. The && means vite build never runs, but npm output can look like mere warnings. Result: dist/ stays stale for hours/days. Fix: change build to "build": "tsc -b || true && vite build" when that strict gate is not intended to block deploys. Always verify dist was updated: stat dist/assets/index-*.js | grep Modify. If stale, run npx vite build directly as a deployment fallback.git pull --autostash can leave .env.local conflicted even after a successful fast-forward: This happens when the remote also changed .env.local. After pull, immediately run git status --short and inspect unmerged files before building/restarting. For deploys, resolve the conflict deliberately (usually keep the local/stashed env file if it contains the real secrets/working overrides), then git add .env.local to mark it resolved. Do NOT restart PM2 with conflict markers left in .env.local.node server.js & or node -e "import('./server')..." before starting PM2, the background process holds the port. PM2 starts successfully but the OLD process serves requests — missing any new routes. Symptoms: existing routes work, new routes return SPA HTML (catch-all). Fix: always kill the stale PID (ss -tlnp | grep PORT to find it) before pm2 start/restart. Or just never use & for manual tests — use curl after PM2 restart instead.app.use(): When wiring a new Express router into a large server.ts, the import { createXRoutes } from '...' line and the app.use('/x', createXRoutes(...)) mount line are independent. It is easy to add only the import (because that's where your cursor lands first), commit, push, deploy, and watch the smoke test still return the SPA HTML for the new path because the router was never actually mounted. Verification rule before committing/restarting: grep -nE "createXRoutes" server.ts MUST show at least TWO hits — one import, one app.use(...). After PM2 restart, the very first smoke curl MUST be against the new path with -w "HTTP:%{http_code} CT:%{content_type}\n"; a text/html content-type on a path that should return JSON means the mount line is missing or the router fell through to express.static(dist). Catching this before commit avoids a "second push" follow-up commit like Mount /x router before SPA fallback.enabled flag and per-venue/class/session allocation table, both defaulting to false/0 so nothing is exposed until an admin enables it; (2) PII segregation — never auto-create normal app users from partner users, store partner identity in dedicated partner_* tables and denormalize safe roster fields only; (3) idempotency on partner-supplied IDs — SELECT ... FOR UPDATE on the partner reservation ID inside a transaction with pg_advisory_xact_lock(schedule_id) so duplicate retries return the same success and conflicting duplicates return ALREADY_RESERVED; (4) inbound audit log — partner_api_requests capturing route/method/status/duration/sanitized-error per call, NO full PII bodies; (5) attendance/status mapping — explicit mapping from your internal statuses to the partner's enum (ENROLLED/ATTENDED/CANCELLED/LATE_CANCELLED/MISSED/CLASS_CANCELLED), because partner payouts depend on this and drift means you get paid wrong. See references/classpass-inbound-partner-api.md for the concrete shape used for /cp/v1 on jungle-mobile.009_classpass_integration.sql but the live repo already has a 009_dual_access_auth.sql from a parallel branch, do NOT silently overwrite. The fix is to ls migrations/ first, pick the next free number (010_classpass_integration.sql here), and update any matching dashboard-side mirror filename if the convention is "same number in both repos". Migrations must remain idempotent (CREATE TABLE IF NOT EXISTS, INSERT ... ON CONFLICT DO NOTHING, CREATE INDEX IF NOT EXISTS) so re-applying after rename is safe.env(safe-area-inset-bottom) padding), touch-optimized targets (min 44px), and active-state scale transforms. Put the primary action (prompt/input) at the top, controls in collapsible sections, results below. Only widen to multi-column on desktop via media query.header/action, run row, list item row) vertically at mobile breakpoints, make primary/secondary buttons full-width, increase textarea/input font size to 1rem for iOS zoom safety, enlarge checkbox tap targets, allow long labels/JSON with overflow-wrap:anywhere, and slightly reduce card padding/radius on very small screens. Without these secondary fixes, a view can technically become one column but still feel unusable on mobile.controllerchange: capture const hadController = Boolean(navigator.serviceWorker.controller) before register('/sw.js'), and only reload on controllerchange if hadController was true. A brand-new install can otherwise be claimed and immediately reload itself into a stuck white launch. Second, never read/write localStorage directly during first render for admin tokens; wrap it in try/catch helpers because iOS standalone/private/partitioned storage can throw. Add a React error boundary with a visible reload/open-admin fallback so startup failures are diagnosable instead of blank.caches.match() || fetch()), users never see new deploys. Fix: (1) Use network-first strategy for HTML/JS/CSS (fetch().then(cache) || caches.match()), (2) Serve sw.js with Cache-Control: no-cache, no-store, must-revalidate headers from Express (add a dedicated route BEFORE express.static) and also set Service-Worker-Allowed: /, (3) Serve manifest.json with the same no-cache headers so icon/name/start_url changes propagate, (4) Call self.skipWaiting() in install and self.clients.claim() in activate, (5) Delete old caches in activate: caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))), (6) In the client, call navigator.serviceWorker.register('/sw.js'), immediately registration.update(), and listen for controllerchange with the hadController guard described above before window.location.reload() so installed PWAs pick up fresh deploys without first-install reload loops, and (7) Optionally poll registration.update() every 60s while the app is open for faster propagation after deploys. Bump the CACHE_NAME on meaningful deploys (e.g. jungle-v1 -> jungle-v2) when cached shell assets change significantly.shrink-0 in a h-dvh container) for a bottom tab bar works in browsers but NOT reliably in PWA standalone mode on iOS — the bar gets pushed below the viewport. Use fixed bottom-0 left-0 right-0 z-50 on the nav element and add pb-[calc(TAB_HEIGHT+env(safe-area-inset-bottom))] to the main content areabg-jungle-tab-bg requires --color-jungle-tab-bg in @theme. If the CSS has --color-jungle-tab-bar instead, the class resolves to transparent. Always verify token names match exactly between CSS and component classesuv pip install Pillow --python .../venv/python3), render using ImageDraw.arc() for geometric logos. Generate 512px (Android), 192px (Android home), 180px (apple-touch-icon). Add apple-touch-icon to both manifest.json and index.html <link rel="apple-touch-icon">.mode='P') with a transparency byte array (img.info['transparency']). PIL's convert('RGBA') returns WHITE pixels with varying alpha, NOT the palette color. To extract the actual shape for recoloring, use numpy on the converted RGBA array and replace the RGB channels while preserving the alpha channel as the mask:import numpy as np
from PIL import Image
logo = Image.open('logo.png').convert('RGBA')
arr = np.array(logo)
alpha = arr[:,:,3].copy()
# Recolor to desired brand color
arr[:,:,0], arr[:,:,1], arr[:,:,2] = RED, GREEN, BLUE
arr[:,:,3] = alpha
logo_clean = Image.fromarray(arr, 'RGBA')
manifest.json background_color to the SAME dark color as the app's body background (e.g., #2B2926), NOT a light splash color. In index.html, add both:<meta name="theme-color" content="#2B2926" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#2B2926">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
And in CSS ensure body { background-color: var(--color-background); padding-top: env(safe-area-inset-top); }. This makes the iOS status bar area blend seamlessly into the app background instead of showing a contrasting color.
- manifest.json needs no-cache headers too: Just like sw.js, serve manifest.json with Cache-Control: no-cache, no-store, must-revalidate from Express so icon, theme, and name changes propagate immediately to installed PWAs without requiring a reinstall.
- Containerized tenants: env vars in tenant .env files do NOT auto-propagate into child processes spawned by the runtime: When a multi-tenant control plane provisions docker containers (e.g. one container per tenant Hermes home), the container's Config.Env is fixed at docker run time. Writing FOO=bar into the mounted tenant .env file does not put FOO into os.environ for Python/Node tools the agent runs inside the container. Symptom: skill or tool code reads os.environ.get('SOMETHING'), finds nothing, silently falls back to a default path (often ~/.hermes/... inside the container's writable layer instead of the mounted volume), claims success, but nothing persists to the host. Fix: bake required env vars into the docker run -e FOO=bar command at container creation. The .env file is for documentation and for the agent to read explicitly via skills — not for ambient inheritance. After fixing the docker run line, you must RECREATE the existing containers (docker rm -f then docker run); a plain docker restart keeps the original Config.Env. Verify with docker inspect <container> --format '{{.Config.Env}}' and docker exec <container> env | grep FOO. This bit me on Astral when ASTRAL_KB_ROOT was set in tenant .env files but the astral-tenant-kb skill couldn't see it — agent confidently said "saved to KB" while writing to a path that vanished on container restart.
- Verifying multi-tenant isolation when concerned about cross-tenant leakage: When the user worries about whether one tenant can see another's data, give grounded evidence not assurances. Five quick checks: (1) ls /srv/<root>/tenants/<id>/hermes-home/knowledge per tenant — confirm each has its own dir with 0700 perms; (2) docker inspect <container> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}' per container — confirm each only mounts its own tenant dir; (3) docker inspect <container> --format 'CapDrop={{.HostConfig.CapDrop}} Privileged={{.HostConfig.Privileged}}' — confirm --cap-drop=ALL and no privileged mode; (4) probe test: write a sentinel file in tenant A's volume, then docker exec <container-B> ls /data/hermes/<path> to confirm B can't see it; (5) review app-level middleware that gates tenant access (requireTenantOwner or equivalent) — confirm the user-to-tenant binding is enforced server-side, not just client-side. Capture the actual command outputs in your response — the user wants to see the evidence, not be told "yes it's secure".
For updating existing apps when the remote repo is the source of truth.
cd /home/avalon/apps/APP_NAME
git status --short
git log --oneline -1
git fetch origin
git status -sb
git log --oneline HEAD..origin/main
For Alex's existing apps, do not blindly discard uncommitted local work when asked to "pull and deploy latest". First inspect it. If there are tracked/untracked changes and the request is only to deploy remote latest, preserve them in a named stash, then fast-forward pull:
git stash push -u -m "pre-deploy-local-changes-$(date +%Y%m%d-%H%M%S)"
git pull --ff-only
Report the stash name in the final response so the work can be recovered.
Only discard local changes when the user explicitly asks to throw them away or when you have confirmed they are disposable:
git checkout -- . # discard tracked changes
git clean -fd # remove untracked files
If git clean -fd hangs, remove untracked files manually:
rm -f UNTRACKED_FILE1 UNTRACKED_FILE2
If the working tree is clean, pull directly:
git pull --ff-only
| App | Build Command | Notes |
|---|---|---|
| jungle-studio | npm run build |
tsc -b \|\| true && vite build — TS errors don't block |
| jungle-mobile | npm run deploy |
npx vite build — skips TS checking entirely |
| video-story | npm run build |
Standard Vite build |
| todos | npm run build |
Standard Vite build |
Always verify dist/ was actually updated: stat dist/assets/index-*.js | grep Modify
pm2 restart APP_NAME
Verify runtime paths if started manually:
pm2 show APP_NAME | grep -E "cwd|script|args"
# Local port
curl -s -o /dev/null -w "%{http_code}" http://localhost:PORT/
# Public URL (jungle-studio returns 401 = expected basic auth)
curl -s -o /dev/null -w "%{http_code}" https://APP.apps.poofc.com/
| App | Path | Port | Public URL | Auth |
|---|---|---|---|---|
| jungle-studio | /home/avalon/apps/jungle-studio-dashboard |
4010 | jungle-studio.apps.poofc.com | Basic auth |
| jungle-mobile | /home/avalon/apps/jungle-studio-mobile |
4011 | jungle-mobile.apps.poofc.com | None |
| Item | Value |
|---|---|
| Path | /home/avalon/apps/thursday |
| Repo | firemountain/thursday |
| URL | https://thursday.apps.poofc.com |
| PM2 | thursday-api, thursday-web |
| API port | 3019 (API_PORT=3019) |
| Web port | 4019 |
| DB | Postgres via DATABASE_URL in .env, host port 55432 |
| Build | npm run build (tsc -b && vite build) |
| DB commands | npm run db:generate, npm run db:push, npm run db:seed |
Thursday is a Vite React web app plus compiled TypeScript Fastify API. PM2 runs API from dist/api/src/server.js, so API source changes require npm run build before pm2 restart thursday-api. The web build outputs to dist/web and is served by thursday-web. After schema changes, run npm run db:generate before lint/build so Prisma Client exposes new models; run npm run db:push before exercising new endpoints. App-level admin auth belongs in Thursday itself; do not add nginx basic auth to guest/PWA flows. Required admin env vars include THURSDAY_ADMIN_PASSWORD, THURSDAY_ADMIN_SESSION_SECRET, and THURSDAY_ADMIN_PIN=3693; fail closed if missing, do not provide default credentials or hardcoded PINs. The PIN-protected table QR manager endpoint is /api/admin/paradiso/tables?pin=...; after PIN/admin changes verify the correct PIN returns 200 and an old/wrong PIN returns 401, e.g. public curls against https://thursday.apps.poofc.com/api/admin/paradiso/tables?pin=3693 and ?pin=3696. Promotion/admin features should verify with local curls against port 3019, then public curls against https://thursday.apps.poofc.com/ and /admin. Thursday API health is /api/health (not /health); use curl http://localhost:3019/api/health and curl https://thursday.apps.poofc.com/api/health for deploy verification. For live Toast changes, also smoke-test a configured bill endpoint such as https://thursday.apps.poofc.com/api/t/paradiso/table-19/bill.
Do NOT copy local-machine .env ports directly onto the VPS. The VPS production runtime uses API port 3019 and web port 4019 behind nginx, with Postgres on host port 55432. For the built public web app, prefer VITE_API_URL="" (same-origin /api through nginx) instead of http://localhost:4000; otherwise browsers on the public site may try to call their own localhost or the wrong port. Toast live data requires local, uncommitted .env values for TOAST_API_BASE, TOAST_CREDENTIAL_SET, TOAST_CLIENT_ID, TOAST_CLIENT_SECRET, optional ERA credentials, TOAST_GUID_PARADISO, TOAST_LIVE_TABLE_19_ENABLED, and TOAST_LIVE_TABLES. Never commit real Toast credentials.
After changing Thursday .env, restart API with env update: pm2 restart thursday-api --update-env && pm2 save. Verify in order:
curl -s http://localhost:3019/api/dev/toast/auth
curl -s "http://localhost:3019/api/dev/toast/orders?businessDate=$(TZ=America/Chicago date +%Y%m%d)&pageSize=5"
curl -s "https://thursday.apps.poofc.com/api/admin/paradiso/tables?pin=3693"
curl -s https://thursday.apps.poofc.com/api/t/paradiso/table-19/bill
/api/dev/toast/auth should show configured: true. If a table is listed in TOAST_LIVE_TABLES but /api/t/paradiso/table-N/bill returns Bill not found, try POST /api/dev/toast/sync/table-N; a 404 No open live Toast check found for that table means env/auth is working but Toast currently has no open check for that table.
HD Prism has a separate Express/SQLite API (apps/api/) that runs from compiled JavaScript in dist/, NOT from the TypeScript source directly.
apps/api/ — Express + SQLite, runs from dist/ (compiled from src/*.ts via npx tsc)apps/hdprism/ — Next.js 14+ apphd-prism-api (port 3005) and hd-prism-client (port 4016)firemountain/hermes-hdkit on GitHubapps/api/descriptions.sqlite (SQLite, NOT in git)hd_auth cookieThe API runs from apps/api/dist/routes/*.js. Editing src/routes/charts.ts does NOTHING until you recompile:
cd /home/avalon/apps/hd-prism/apps/api && npx tsc
Then restart: pm2 restart hd-prism-api
Symptoms of stale dist/: New fields added to API routes don't appear in responses. Frontend filters/features that depend on new API fields silently fail because the data isn't there.
The sidebar uses a FILTER_CATEGORIES registry pattern for chart filtering:
- Each category has: key (field name), label, mode ("scalar" or "array"), optional staticOptions/dynamicOptions
- chartPassesFilters() and FilterDrawer work generically over the registry
- To add a new filter (e.g., gates, channels, authority): just add an entry to FILTER_CATEGORIES in Sidebar.tsx
- Current active filters: type, profile. Future: authority, definition, gates, channels (all already returned by API, commented out in registry)
GET /api/charts now returns per-chart: type, profile, authority, definition, gates (number[]), channels (string[]) — all parsed from the chart_json blob without DB migration.
cd /home/avalon/apps/hd-prism/apps/api && npx tsc # rebuild API
cd /home/avalon/apps/hd-prism/apps/hdprism && npx next build # rebuild client
pm2 restart hd-prism-api hd-prism-client