diff --git a/.dockerignore b/.dockerignore index fa217cb7..f323aac2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,14 @@ # Allow demo assets (AIUI pre-built dist) !demo/ +# Allow the Bitcoin UI + ElectrumX UI mock shells (served from /docker/*) +!docker/ +docker/* +!docker/bitcoin-ui/ +!docker/electrs-ui/ +!docker/lnd-ui/ +!docker/fedimint-ui/ + # Allow backend source for ISO source builds !core/ !scripts/ diff --git a/.github/workflows/demo-images.yml b/.github/workflows/demo-images.yml new file mode 100644 index 00000000..72c43ae8 --- /dev/null +++ b/.github/workflows/demo-images.yml @@ -0,0 +1,67 @@ +name: Demo images + +# Builds and pushes the public-demo images on every change to the UI / mock +# backend, so the separated `archy-demo` Portainer stack auto-tracks the real +# code (see demo-deploy/ and docs/demo-deployment-design.md). +# +# Required repo configuration: +# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025 +# secrets.DEMO_REGISTRY_USER +# secrets.DEMO_REGISTRY_TOKEN +# Optional: +# secrets.PORTAINER_WEBHOOK redeploy hook called after a successful push + +on: + push: + branches: [main] + paths: + - 'neode-ui/**' + - 'docker-compose.demo.yml' + - '.github/workflows/demo-images.yml' + workflow_dispatch: + +jobs: + build: + name: Build & push demo images + runs-on: ubuntu-latest + # Skip cleanly on forks / before registry config is set. + if: ${{ vars.DEMO_REGISTRY != '' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: ${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }} + username: ${{ secrets.DEMO_REGISTRY_USER }} + password: ${{ secrets.DEMO_REGISTRY_TOKEN }} + + - name: Build & push backend + uses: docker/build-push-action@v6 + with: + context: . + file: neode-ui/Dockerfile.backend + push: true + tags: | + ${{ vars.DEMO_REGISTRY }}/archy-demo-backend:demo + ${{ vars.DEMO_REGISTRY }}/archy-demo-backend:${{ github.sha }} + + - name: Build & push web + uses: docker/build-push-action@v6 + with: + context: . + file: neode-ui/Dockerfile.web + push: true + build-args: | + VITE_DEMO=1 + tags: | + ${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo + ${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }} + + - name: Trigger Portainer redeploy + if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }} + run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}" diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json index b2ca489b..24b358de 100644 --- a/app-catalog/catalog.json +++ b/app-catalog/catalog.json @@ -195,7 +195,7 @@ "title": "Nostr Relay (Rust)", "version": "0.8.0", "description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.", - "icon": "/assets/img/app-icons/nostr.svg", + "icon": "/assets/img/app-icons/nostrudel.svg", "author": "Nostr RS Relay", "category": "community", "tier": "recommended", diff --git a/demo-deploy/.env.example b/demo-deploy/.env.example new file mode 100644 index 00000000..cab5b316 --- /dev/null +++ b/demo-deploy/.env.example @@ -0,0 +1,18 @@ +# Copy to .env and adjust. Used by demo-deploy/docker-compose.yml. + +# Registry host + namespace that holds the prebuilt demo images. +REGISTRY=146.59.87.168:3000/lfg2025 + +# Image tag to deploy (CI publishes :demo and :). +IMAGE_TAG=demo + +# Host port for the demo UI. +DEMO_WEB_PORT=2100 + +# Optional — enables the in-app AI chat panel. Leave blank to disable. +ANTHROPIC_API_KEY= + +# Optional sandbox tuning (defaults shown). +DEMO_SESSION_TTL_MS=2700000 # 45 min idle before a visitor session is reaped +DEMO_MAX_SESSIONS=500 # concurrent visitor cap +DEMO_FILE_QUOTA_BYTES=52428800 # 50 MB uploads per visitor diff --git a/demo-deploy/README.md b/demo-deploy/README.md new file mode 100644 index 00000000..3d553f27 --- /dev/null +++ b/demo-deploy/README.md @@ -0,0 +1,33 @@ +# Archipelago — Public Demo deploy + +A click-to-play demo of the Archipelago UI, backed entirely by a mock backend. +Every visitor gets an **isolated, ephemeral sandbox** (own apps, wallet, files), +real container runtimes are never touched, and Bitcoin runs on **signet** test +coins. **Login password: `entertoexit`** (shown on the login screen). + +This directory is the full contents of the public `archy-demo` repo. It holds no +source — only this compose file that pulls prebuilt `:demo` images. + +## Deploy in Portainer + +1. **Stacks → Add stack → Repository** (or paste `docker-compose.yml` into the web editor). +2. Set environment variables (see `.env.example`) — at minimum `REGISTRY`, and + `ANTHROPIC_API_KEY` if you want the AI chat panel. +3. Deploy. The UI is served on `:2100` (override with `DEMO_WEB_PORT`). + +To pick up a new build, redeploy the stack (or wire the CI Portainer webhook). + +## How it stays current + +The images are built from the Archipelago monorepo by +`.github/workflows/demo-images.yml` on every change to `neode-ui/`, tagged `:demo` +and `:`, and pushed to `REGISTRY`. Editing the real UI → CI rebuilds → +redeploy here. No source lives in this repo. + +## What's mocked + +- **Per-visitor isolation** — state keyed by a `demo_sid` cookie, idle-reaped. +- **Apps** — install/uninstall/start/stop are simulated (no real Docker). +- **Wallet/Bitcoin** — signet-flavored; use the in-UI faucet for test sats. +- **Files** — real per-session upload/rename/delete, 50 MB quota, wiped on reap. +- **Intro** — replays once per calendar day per browser. diff --git a/demo-deploy/docker-compose.yml b/demo-deploy/docker-compose.yml new file mode 100644 index 00000000..0f657af0 --- /dev/null +++ b/demo-deploy/docker-compose.yml @@ -0,0 +1,49 @@ +# Archipelago Public Demo — thin deploy stack +# +# This is the ENTIRE contents intended for the public `archy-demo` repo. It holds +# NO source — it pulls prebuilt `:demo` images that CI builds from the monorepo on +# every neode-ui change (see .github/workflows/demo-images.yml). Deploy this in +# Portainer ("deploy from repository" or paste into the web editor). +# +# Demo login password: entertoexit +# Access on http://:2100 +# +# Configure via a .env file (see .env.example): +# REGISTRY registry host/namespace holding the demo images +# IMAGE_TAG image tag to pull (default: demo) +# ANTHROPIC_API_KEY optional — enables the AI chat panel +# DEMO_WEB_PORT host port for the UI (default 2100) + +services: + neode-backend: + image: ${REGISTRY:-146.59.87.168:3000/lfg2025}/archy-demo-backend:${IMAGE_TAG:-demo} + container_name: archy-demo-backend + environment: + DEMO: "1" + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + NODE_OPTIONS: "--dns-result-order=ipv4first" + DEMO_SESSION_TTL_MS: ${DEMO_SESSION_TTL_MS:-2700000} + DEMO_MAX_SESSIONS: ${DEMO_MAX_SESSIONS:-500} + DEMO_FILE_QUOTA_BYTES: ${DEMO_FILE_QUOTA_BYTES:-52428800} + expose: + - "5959" + dns: + - 8.8.8.8 + - 1.1.1.1 + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:5959/health"] + interval: 30s + timeout: 10s + retries: 3 + + neode-web: + image: ${REGISTRY:-146.59.87.168:3000/lfg2025}/archy-demo-web:${IMAGE_TAG:-demo} + container_name: archy-demo-web + ports: + - "${DEMO_WEB_PORT:-2100}:80" + environment: + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + depends_on: + - neode-backend + restart: unless-stopped diff --git a/demo/README-curated-files.md b/demo/README-curated-files.md new file mode 100644 index 00000000..58593c8f --- /dev/null +++ b/demo/README-curated-files.md @@ -0,0 +1,22 @@ +# Curated demo files + +Drop real files into `demo/files/` to make them the cloud's content for **every** +demo visitor (read-only — visitors can browse, download, and "buy" them, but only +maintainers add them). This is the "private login": the only way to add files is +to commit them here, which requires repo access. + +``` +demo/files/ + Documents/whitepaper.pdf + Photos/rig.jpg + Music/track.mp3 +``` + +- Folder structure becomes the cloud's folders. +- Text files (`.md .txt .json .csv …`, < 1 MB) are inlined; everything else is + streamed from disk on download. +- If `demo/files/` is empty, the demo falls back to the built-in seeded set + (Documents/Photos/Music/Videos with sample content). + +After adding files, commit and push — CI rebuilds the `:demo` image and Portainer +redeploys. Keep the total modest (these load into the demo image). diff --git a/demo/aiui/index.html b/demo/aiui/index.html index bb1a78b2..241e7854 100644 --- a/demo/aiui/index.html +++ b/demo/aiui/index.html @@ -14,6 +14,31 @@ AIUI + + diff --git a/demo/files/.gitkeep b/demo/files/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/docker-compose.demo.yml b/docker-compose.demo.yml index a9ca9a91..cb083fd7 100644 --- a/docker-compose.demo.yml +++ b/docker-compose.demo.yml @@ -1,6 +1,13 @@ -# Archipelago Demo Stack - Mock backend + Vue UI + AIUI Chat -# Deploy via Portainer: Web editor -> paste this, or deploy from repo -# Access at http://localhost:4848 +# Archipelago Public Demo Stack - Mock backend + Vue UI + AIUI Chat +# Deploy via Portainer: Web editor -> paste this, or deploy from repo (build). +# Access at http://localhost:2100 +# +# This builds the demo images from source. For the separated, auto-updating +# deploy that pulls prebuilt :demo images, see demo-deploy/docker-compose.yml. +# +# DEMO=1 turns on the public multi-visitor sandbox: each visitor gets an +# isolated, ephemeral copy of all state; real container runtimes are never +# touched; the shared login password is "entertoexit". # # Required: Set ANTHROPIC_API_KEY in environment or .env file for chat to work # IndeedHub is deployed as a separate Portainer stack (indee-demo repo) @@ -12,9 +19,13 @@ services: dockerfile: neode-ui/Dockerfile.backend container_name: archy-demo-backend environment: - VITE_DEV_MODE: "existing" + DEMO: "1" ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} NODE_OPTIONS: "--dns-result-order=ipv4first" + # Optional tuning (defaults shown): + # DEMO_SESSION_TTL_MS: "2700000" # 45 min idle before a session is reaped + # DEMO_MAX_SESSIONS: "500" # concurrent visitor cap + # DEMO_FILE_QUOTA_BYTES: "52428800" # 50 MB uploads per visitor expose: - "5959" dns: @@ -31,9 +42,11 @@ services: build: context: . dockerfile: neode-ui/Dockerfile.web + args: + VITE_DEMO: "1" container_name: archy-demo-web ports: - - "4848:80" + - "2100:80" depends_on: - neode-backend restart: unless-stopped diff --git a/docs/demo-build-info.md b/docs/demo-build-info.md new file mode 100644 index 00000000..c49441a9 --- /dev/null +++ b/docs/demo-build-info.md @@ -0,0 +1,109 @@ +# Archipelago Public Demo — build info & status + +**Status:** implemented & deployable (2026-06-22) +**Branch:** `demo-build` (worktree `../archy-demo-build`), pushed to +`gitea-vps2` = `http://146.59.87.168:3000/lfg2025/archy.git`. +**Main/prod is untouched** — all demo work lives only on `demo-build`. + +A public, click-to-play demo of the Archipelago UI, 100% mock-data driven, +multi-visitor, deployed via Portainer. See also `docs/demo-deployment-design.md` +(original design) and `demo-deploy/` (thin prebuilt-image stack). + +--- + +## Deploy (Portainer) + +Build-from-repo (works today, no registry needed): + +| Field | Value | +|-------|-------| +| Repository URL | `http://146.59.87.168:3000/lfg2025/archy.git` | +| Reference | `refs/heads/demo-build` | +| Compose path | `docker-compose.demo.yml` | +| Auth | user `lfg2025`, password = Gitea token | +| UI port | **2100** · Login password: **`entertoexit`** | + +Redeploy after each push. `docker-compose.demo.yml` builds two images +(`neode-ui/Dockerfile.backend` = mock server, `neode-ui/Dockerfile.web` = nginx+UI). +The thin `demo-deploy/docker-compose.yml` pulls prebuilt `:demo` images instead +(needs the CI image pipeline / registry wired — `.github/workflows/demo-images.yml`). + +### Flags / env +- Backend: `DEMO=1` (compose sets it) → multi-session sandbox, no real runtime. +- Web build: `VITE_DEMO=1` (Dockerfile.web ARG, default 1) → inlined demo UI behaviour. +- Optional: `ANTHROPIC_API_KEY` (NOT needed — AIUI chat is canned in demo), + `DEMO_SESSION_TTL_MS` (45m), `DEMO_MAX_SESSIONS` (500), `DEMO_FILE_QUOTA_BYTES` (50MB). + +--- + +## Architecture + +Everything is gated behind `DEMO` (off = classic single-user dev mock, unchanged). + +- **`neode-ui/mock-backend.js`** — the entire fake backend (Node/Express, ~95+ RPCs). + - **Per-session isolation:** `AsyncLocalStorage` + Proxy. Globals (`mockData`, + `walletState`, `userState`, `mockState`, `bitcoinRelayMockState`) are Proxies + that resolve to the current request's store, keyed by a `demo_sid` cookie. + Deep-cloned from `SEED_*` on first hit; idle-reaped; per-session WS fan-out. + - **Files:** per-session in-memory store + curated disk files (see below). + - Forces simulation mode in DEMO (`docker=null`). +- **`neode-ui/src/composables/useDemoIntro.ts`** — the frontend demo switch + (`IS_DEMO`), per-day intro gate, `DEMO_PASSWORD`, app demoability + launch URLs. +- **`neode-ui/docker/nginx-demo.conf`** — routes `/rpc`, `/ws`, `/app/*`, + `/electrs-status`, `/proxy/`, `/lnd-connect-info`, the IndeeHub/Mempool + reverse-proxies, and the SPA. +- **`docker/{bitcoin-ui,electrs-ui,lnd-ui,fedimint-ui}/`** — the REAL registry app + UIs, served statically under `/app//` with mocked data endpoints. +- **`demo/aiui/`** — prebuilt AIUI dist (chat is canned; `?mockArchy&seed`). +- **`demo/files/`** — curated cloud files drop-in (see below). + +## Demo features (all implemented) +Per-session sandbox · per-session file upload (Range streaming) · testnet/signet +flavor · per-day intro replay · `entertoexit` login (prefilled + hint) · version +`-demo` · onboarding wizard skipped (intro kept) · "No demo" install gating · +real app UIs (Bitcoin Core vs Knots by subversion, ElectrumX, LND, Fedimint; +Mempool/IndeeHub iframed) · 12 federation nodes / 5 peers · FIPS active · interactive +buy flow (testnet addresses, bolt11, 2s QR) · real testnet tx links (mempool.space) · +networking profits 5,231,978 sats + labelled wallet txs · VPN · Nostr relays · +node-visibility toggle · dummy Cashu mints + Fedimint federations · AIUI canned +reply + `?mockArchy` mock data + `?seed` pre-loaded "Content Showcase" chat. + +--- + +## Curated cloud files (`demo/files/`) +Drop real files into `demo/files//` and commit — they become the +cloud content for every visitor (read-only; git access = the "private login"). +Loader **merges per top-level folder**: adding `Music/` swaps only Music and keeps +the sample Documents/Photos/Videos. Empty → built-in seeds. Text inlined; binaries +streamed from disk with HTTP Range (seek). Backend reads `/demo/files` — +**Dockerfile.backend COPYs it; `.dockerignore` must allow it.** + +--- + +## Gotchas (READ before editing) +- **Sibling dirs need both the Dockerfile COPY and a `.dockerignore` allow.** + `docker/bitcoin-ui`, `docker/electrs-ui`, `docker/lnd-ui`, `docker/fedimint-ui`, + `demo/files` are outside `neode-ui/`; they're copied into the backend image and + un-ignored in `.dockerignore` (`* ` + `!docker/` + `docker/*` + `!docker//`). + Forgetting either → Portainer build "not found" or runtime 500/404. +- **Real app UIs assume root-serving** — served via `express.static('/app/')` + + `/app//assets/*` → `/assets/*` redirect + per-path data endpoints + (`bitcoin-status`, `rpc/v1`, `bitcoin-rpc/`, `/proxy/lnd/*`, `/electrs-status`). +- **Uploaded-via-UI files are ephemeral** (per-session, lost on redeploy/reap). + Only `demo/files/` persists. +- **Mempool iframe is best-effort** (third-party CSP/websockets). **IndeeHub** is + reverse-proxied with header-strip + `sub_filter` asset rewrite; if still black, + it's indee's own `X-Frame-Options` (fix on that server). +- **AIUI `?seed` bootstrap hardcodes the current AIUI bundle hash** + (`/aiui/assets/seedPrompts-CLWaUv28.js`) — re-paste if AIUI is rebuilt. Tiny + first-load IndexedDB race (one refresh shows the chat). +- **Running mock-backend.js locally in the sandbox is flaky:** start backgrounded, + `sleep 5+`, then curl; NEVER `pkill -f mock-backend` (it matches & kills the + shell) — use `pkill -x node`. +- **Delete-405** seen pre-redeploy was nginx/stale; backend DELETE returns 200. + +--- + +## Commit trail (demo-build, newest last) +`2715f2d8` sandbox → … → `7efebb4a` media merge + AIUI seed. ~14 commits, all +`feat(demo)/fix(demo)`. diff --git a/neode-ui/Dockerfile.backend b/neode-ui/Dockerfile.backend index 1881cc70..f9ed0339 100644 --- a/neode-ui/Dockerfile.backend +++ b/neode-ui/Dockerfile.backend @@ -14,6 +14,14 @@ RUN npm install # Copy application code COPY neode-ui/ ./ +# Sibling assets the mock backend reads relative to /app (../docker, ../demo): +# the Bitcoin UI mock shell and any curated cloud files dropped into demo/files. +COPY docker/bitcoin-ui /docker/bitcoin-ui +COPY docker/electrs-ui /docker/electrs-ui +COPY docker/lnd-ui /docker/lnd-ui +COPY docker/fedimint-ui /docker/fedimint-ui +COPY demo/files /demo/files + # Expose port EXPOSE 5959 diff --git a/neode-ui/Dockerfile.web b/neode-ui/Dockerfile.web index 480dfe41..f6be9924 100644 --- a/neode-ui/Dockerfile.web +++ b/neode-ui/Dockerfile.web @@ -20,6 +20,12 @@ RUN find public/assets -name "*backup*" -type f -delete || true && \ ENV DOCKER_BUILD=true ENV NODE_ENV=production +# Public-demo build flag — inlined into the bundle (import.meta.env.VITE_DEMO). +# Enables the per-day intro replay, the "entertoexit" login hint, and other +# demo-only UI affordances. Override with --build-arg VITE_DEMO=0 for a plain build. +ARG VITE_DEMO=1 +ENV VITE_DEMO=$VITE_DEMO + # Use npm script which handles build better RUN npm run build:docker || (echo "Build failed! Listing files:" && ls -la && echo "Checking vite config:" && cat vite.config.ts && exit 1) diff --git a/neode-ui/docker/nginx-demo.conf b/neode-ui/docker/nginx-demo.conf index a80acba5..01bfa11e 100644 --- a/neode-ui/docker/nginx-demo.conf +++ b/neode-ui/docker/nginx-demo.conf @@ -62,6 +62,28 @@ http { proxy_set_header X-Real-IP $remote_addr; } + # ElectrumX UI status (polled by the electrs-ui shell) + location /electrs-status { + proxy_pass http://neode-backend:5959; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # LND UI endpoints (polled by the lnd-ui shell) + location /proxy/ { + proxy_pass http://neode-backend:5959; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + location /lnd-connect-info { + proxy_pass http://neode-backend:5959; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + # Proxy FileBrowser API to mock backend (demo mode) location /app/filebrowser/ { client_max_body_size 10G; @@ -72,6 +94,59 @@ http { proxy_request_buffering off; } + # IndeeHub: reverse-proxy the real site same-origin, strip framing headers, + # and rewrite its absolute asset paths (/assets, /, src, href) to the + # /app/indeedhub/ prefix so the SPA loads inside the iframe. + location /app/indeedhub/ { + proxy_pass https://indee.tx1138.com/; + proxy_http_version 1.1; + proxy_set_header Host indee.tx1138.com; + proxy_set_header Accept-Encoding ""; + proxy_ssl_server_name on; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_hide_header Content-Security-Policy-Report-Only; + sub_filter_types text/html text/css application/javascript application/json; + sub_filter_once off; + sub_filter 'href="/' 'href="/app/indeedhub/'; + sub_filter 'src="/' 'src="/app/indeedhub/'; + sub_filter "href='/" "href='/app/indeedhub/"; + sub_filter "src='/" "src='/app/indeedhub/"; + sub_filter 'from"/' 'from"/app/indeedhub/'; + sub_filter 'url(/' 'url(/app/indeedhub/'; + } + + # Mempool: same approach. NOTE mempool.space is a strict third-party app — + # its data/websocket calls may still be blocked; iframe is best-effort. + location /app/mempool/ { + proxy_pass https://mempool.space/; + proxy_http_version 1.1; + proxy_set_header Host mempool.space; + proxy_set_header Accept-Encoding ""; + proxy_ssl_server_name on; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_hide_header Content-Security-Policy-Report-Only; + sub_filter_types text/html text/css application/javascript application/json; + sub_filter_once off; + sub_filter 'href="/' 'href="/app/mempool/'; + sub_filter 'src="/' 'src="/app/mempool/'; + sub_filter "href='/" "href='/app/mempool/"; + sub_filter "src='/" "src='/app/mempool/"; + sub_filter 'from"/' 'from"/app/mempool/'; + sub_filter 'url(/' 'url(/app/mempool/'; + } + + # Proxy every other app UI (/app//) to the mock backend, which serves + # the per-app mock UIs (bitcoin-ui, electrumx, lnd, fedimint) and the + # generic "Not available in the demo" notice for the rest. + location /app/ { + proxy_pass http://neode-backend:5959; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + # Serve AIUI SPA location /aiui/ { alias /usr/share/nginx/html/aiui/; diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index eeb76e16..2831631a 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -17,14 +17,35 @@ import fs from 'fs/promises' import path from 'path' import { fileURLToPath } from 'url' import Docker from 'dockerode' +import { AsyncLocalStorage } from 'node:async_hooks' +import crypto from 'crypto' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const execPromise = promisify(exec) +// DEMO mode: public, multi-visitor sandbox. Each visitor gets an isolated, +// ephemeral copy of all mutable state (see per-session store below), real +// container runtimes are never touched, and idle sessions are reaped. +// When DEMO is off, behaviour is identical to the classic single-user dev mock. +const DEMO = + process.env.DEMO === '1' || + process.env.VITE_DEMO === '1' || + process.env.VITE_DEV_MODE === 'demo' + // Find container socket: Podman (macOS/Linux) or Docker -import { existsSync } from 'fs' +import { existsSync, readFileSync } from 'fs' +import * as fsSync from 'fs' + +// Report the real app version, suffixed with -demo in the public sandbox so it's +// obviously the demo while still tracking whatever version the UI ships. +let APP_VERSION = '0.1.0' +try { + const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')) + if (pkg.version) APP_VERSION = pkg.version +} catch { /* fall back to default */ } +if (DEMO) APP_VERSION += '-demo' function findContainerSocket() { // DOCKER_HOST env var (set by podman machine start) @@ -47,9 +68,13 @@ function findContainerSocket() { return null } -const containerSocket = findContainerSocket() +// In DEMO mode we never bind to a real container runtime — the public demo must +// be host-independent and unable to touch the host's Docker/Podman. +const containerSocket = DEMO ? null : findContainerSocket() const docker = containerSocket ? new Docker({ socketPath: containerSocket }) : null -if (containerSocket) { +if (DEMO) { + console.log('[Container] DEMO mode — simulation only (real runtime disabled)') +} else if (containerSocket) { console.log(`[Container] Socket: ${containerSocket}`) } else { console.log('[Container] No socket found — simulation mode (no Docker/Podman)') @@ -84,12 +109,25 @@ app.use((req, res, next) => { }) app.use(cookieParser()) +// DEMO: bind every request to an isolated per-visitor state store (keyed by the +// `demo_sid` cookie) for the remainder of the request. Outside DEMO this is a +// no-op and all handlers share the single default store (classic mock behaviour). +app.use((req, res, next) => { + if (!DEMO) return next() + const store = resolveSessionStore(req, res) + stateContext.run(store, () => next()) +}) + // Mock session storage const sessions = new Map() -const MOCK_PASSWORD = 'password123' +// Public demo uses a memorable shared password (shown on the login screen); +// the classic dev mock keeps password123. +const MOCK_PASSWORD = DEMO ? 'entertoexit' : 'password123' -// Mutable wallet state — faucet/send/receive modify these values -const walletState = { +// Mutable wallet state — faucet/send/receive modify these values. +// SEED_* objects are pristine templates; each demo session gets a deep clone +// (see makeSessionStore). `walletState` itself becomes a session-aware proxy below. +const SEED_WALLET = { onchain_sats: 2_350_000, channel_sats: 8_250_000, ecash_sats: 250_000, @@ -99,11 +137,23 @@ const walletState = { { tx_hash: 'ab12cd34ef5678901234567890abcdef12345678', amount_sats: 2_000_000, direction: 'incoming', num_confirmations: 142, block_height: 892310, time_stamp: Math.floor(Date.now()/1000) - 86400, label: 'Channel funding', total_fees: 0, dest_addresses: [] }, { tx_hash: 'cd34ef5678901234567890abcdef1234567890ab', amount_sats: 250_000, direction: 'incoming', num_confirmations: 28, block_height: 892420, time_stamp: Math.floor(Date.now()/1000) - 7200, label: 'Faucet deposit', total_fees: 0, dest_addresses: [] }, { tx_hash: 'ff99ee88dd7766554433221100aabbccddeeff00', amount_sats: 100_000, direction: 'incoming', num_confirmations: 0, block_height: 0, time_stamp: Math.floor(Date.now()/1000) - 600, label: 'Incoming from faucet', total_fees: 0, dest_addresses: [] }, + // Networking profits — one or two per type, with human labels (these labels + // are intended for the production wallet UI too). + { tx_hash: '1a2b3c4d5e6f78901234567890abcdef11111111', amount_sats: 480_000, direction: 'incoming', num_confirmations: 64, block_height: 892387, time_stamp: Math.floor(Date.now()/1000) - 18000, label: 'Content sale · “Lightning Demo.mp4”', profit_type: 'content_sales', total_fees: 0, dest_addresses: [] }, + { tx_hash: '2b3c4d5e6f7890abcdef1234567890abcd222222', amount_sats: 312_500, direction: 'incoming', num_confirmations: 110, block_height: 892341, time_stamp: Math.floor(Date.now()/1000) - 43200, label: 'Content sale · “Sovereign Computing.pdf”', profit_type: 'content_sales', total_fees: 0, dest_addresses: [] }, + { tx_hash: '3c4d5e6f7890abcdef1234567890abcdef333333', amount_sats: 1_842, direction: 'incoming', num_confirmations: 12, block_height: 892439, time_stamp: Math.floor(Date.now()/1000) - 3600, label: 'Lightning routing fee', profit_type: 'routing_fees', total_fees: 0, dest_addresses: [] }, + { tx_hash: '4d5e6f7890abcdef1234567890abcdef44444444', amount_sats: 906, direction: 'incoming', num_confirmations: 5, block_height: 892446, time_stamp: Math.floor(Date.now()/1000) - 1500, label: 'Lightning routing fee', profit_type: 'routing_fees', total_fees: 0, dest_addresses: [] }, + { tx_hash: '5e6f7890abcdef1234567890abcdef5555555555', amount_sats: 64_200, direction: 'incoming', num_confirmations: 38, block_height: 892410, time_stamp: Math.floor(Date.now()/1000) - 9000, label: 'File relay reward', profit_type: 'relay', total_fees: 0, dest_addresses: [] }, + { tx_hash: '6f7890abcdef1234567890abcdef666666666666', amount_sats: 28_750, direction: 'incoming', num_confirmations: 21, block_height: 892428, time_stamp: Math.floor(Date.now()/1000) - 5400, label: 'Mesh relay forwarding fee', profit_type: 'relay', total_fees: 0, dest_addresses: [] }, ], } function randomHex(bytes) { return Array.from({length: bytes}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join('') } -const bitcoinRelayMockState = { +// Tracks when a content invoice was requested, so the demo can leave the QR on +// screen for a couple of seconds before reporting it paid. +const invoiceRequestedAt = new Map() + +const SEED_BTCRELAY = { settings: { enabled_for_peers: true, allow_peer_requests: true, @@ -112,7 +162,7 @@ const bitcoinRelayMockState = { allow_tor: true, selected_peer_pubkey: '03d9b8a8db6b4f4d8b8d04c7a467c101f04c0ecbabc0e29e4dcb812a3b1c5f8f04', http_endpoint: '', - https_endpoint: 'https://shard.tx1138.com/', + https_endpoint: 'https://relay-' + randomHex(5) + '.example.net/', tor_endpoint: 'http://btc-relay-demoabcdefghijklmnop.onion/', }, trusted_nodes: [ @@ -144,77 +194,59 @@ const bitcoinRelayMockState = { ], } -// User state (simulated file-based storage) -let userState = { - setupComplete: false, - onboardingComplete: false, - passwordHash: null, // In real app, this would be bcrypt hash -} - -let mockState = { analyticsEnabled: false } - -// Initialize user state based on dev mode -function initializeUserState() { - switch (DEV_MODE) { +// User state (simulated file-based storage). Returns a fresh object per session. +// In DEMO the visitor is always treated as fully set up ("existing") so the +// onboarding WIZARD (seed/identity/backup) is never forced by the route guard. +// The welcome INTRO still shows via the frontend's per-day replay gate. +function seedUserState() { + const mode = DEMO ? 'existing' : DEV_MODE + switch (mode) { case 'setup': - // Setup mode: Original StartOS node setup - user needs to set password - // This is the simple password setup, NOT the experimental onboarding - userState = { - setupComplete: false, // User hasn't set password yet - onboardingComplete: false, // Onboarding not relevant for setup mode - passwordHash: null, - } - break + // Setup mode: user needs to set a password (simple setup, not onboarding). + return { setupComplete: false, onboardingComplete: false, passwordHash: null } case 'onboarding': - // Onboarding mode: Experimental onboarding flow - // User has set password (via setup) but needs to go through experimental onboarding - userState = { - setupComplete: true, // Password already set - onboardingComplete: false, // Needs experimental onboarding - passwordHash: MOCK_PASSWORD, - } - break + // Password already set; visitor still needs to go through onboarding/intro. + return { setupComplete: true, onboardingComplete: false, passwordHash: MOCK_PASSWORD } case 'existing': - // Existing user: Fully set up, just needs to login - userState = { - setupComplete: true, - onboardingComplete: true, - passwordHash: MOCK_PASSWORD, - } - break + // Fully set up, just needs to log in. + return { setupComplete: true, onboardingComplete: true, passwordHash: MOCK_PASSWORD } case 'boot': - // Boot mode: Simulate server startup delay (shows boot screen) - // Server responds with 502 for the first 10 seconds, then works like onboarding mode - userState = { - setupComplete: true, - onboardingComplete: false, - passwordHash: MOCK_PASSWORD, - } - break + // Simulate server startup delay (boot screen), then behave like onboarding. + return { setupComplete: true, onboardingComplete: false, passwordHash: MOCK_PASSWORD } default: - // Default: Fully set up (for UI development) - userState = { - setupComplete: true, - onboardingComplete: true, - passwordHash: MOCK_PASSWORD, - } + // Default: fully set up (for UI development). + return { setupComplete: true, onboardingComplete: true, passwordHash: MOCK_PASSWORD } } - console.log(`[Auth] Dev mode: ${DEV_MODE}`) - console.log(`[Auth] Setup: ${userState.setupComplete}, Onboarding: ${userState.onboardingComplete}`) } -initializeUserState() +function seedMockState() { + return { + analyticsEnabled: false, + nodeVisibility: 'discoverable', + cashuMints: [ + 'https://mint.minibits.cash/Bitcoin', + 'https://stablenut.umint.cash', + 'https://mint.coinos.io', + 'https://8333.space:3338', + ], + federations: [ + { federation_id: 'fed1' + randomHex(28), name: 'Archipelago Federation', balance_sats: 180_000 }, + { federation_id: 'fed1' + randomHex(28), name: 'Bitcoin Park Mint', balance_sats: 42_500 }, + ], + } +} -// WebSocket clients for broadcasting updates -const wsClients = new Set() +console.log(`[Auth] Dev mode: ${DEV_MODE}${DEMO ? ' (DEMO multi-session)' : ''}`) -// Helper: Broadcast data update to all WebSocket clients +// Broadcast a data-update patch to the WebSocket clients of the CURRENT session +// only (so demo visitors never see each other's state). Outside a request context +// (e.g. startup) this resolves to the default store, matching single-user mode. function broadcastUpdate(patch) { const message = JSON.stringify({ rev: Date.now(), patch: patch }) - wsClients.forEach(client => { + currentStore().sockets.forEach(client => { if (client.readyState === 1) { // OPEN client.send(message) } @@ -676,10 +708,10 @@ async function uninstallPackage(id) { } // Mock data -const mockData = { +const SEED_MOCKDATA = { 'server-info': { id: 'archipelago-demo', - version: '0.1.0', + version: APP_VERSION, name: 'Archipelago', pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456', 'status-info': { @@ -750,27 +782,28 @@ function staticApp({ id, title, version, shortDesc, longDesc, license, state, la } } -// Static dev apps (always shown in My Apps when using mock backend) +// Static dev apps (always shown in My Apps when using mock backend). const staticDevApps = { + bitcoin: staticApp({ + id: 'bitcoin', + title: 'Bitcoin Core', + version: '28.4.0', + shortDesc: 'Full Bitcoin node', + longDesc: 'Validate every transaction and block. Full consensus enforcement — the bedrock of sovereignty.', + state: 'running', + lanPort: 8332, + icon: '/assets/img/app-icons/bitcoin-core.png', + }), 'bitcoin-knots': staticApp({ id: 'bitcoin-knots', title: 'Bitcoin Knots', - version: '27.1', + version: '28.1.0', shortDesc: 'Full Bitcoin node', longDesc: 'Validate and relay Bitcoin blocks and transactions with the Archipelago custom node UI.', state: 'running', lanPort: 8334, icon: '/assets/img/app-icons/bitcoin-knots.webp', }), - bitcoin: staticApp({ - id: 'bitcoin', - title: 'Bitcoin Core', - version: '27.1', - shortDesc: 'Full Bitcoin node', - longDesc: 'Validate every transaction and block. Full consensus enforcement — the bedrock of sovereignty.', - state: 'running', - lanPort: 8332, - }), lnd: staticApp({ id: 'lnd', title: 'LND', @@ -779,15 +812,17 @@ const staticDevApps = { longDesc: 'Instant Bitcoin payments with near-zero fees. Open channels, route payments, earn sats.', state: 'running', lanPort: 8080, + icon: '/assets/img/app-icons/lnd.svg', }), - electrs: staticApp({ - id: 'electrs', - title: 'Electrs', - version: '0.10.6', - shortDesc: 'Electrum Server in Rust', + electrumx: staticApp({ + id: 'electrumx', + title: 'ElectrumX', + version: '1.18.0', + shortDesc: 'Electrum server', longDesc: 'Private blockchain indexing for wallet lookups. Connect Sparrow, BlueWallet, or any Electrum-compatible wallet.', state: 'running', - lanPort: 50001, + lanPort: 50002, + icon: '/assets/img/app-icons/electrumx.webp', }), mempool: staticApp({ id: 'mempool', @@ -798,6 +833,7 @@ const staticDevApps = { license: 'AGPL-3.0', state: 'running', lanPort: 4080, + icon: '/assets/img/app-icons/mempool.webp', }), lorabell: staticApp({ id: 'lorabell', @@ -828,15 +864,6 @@ const staticDevApps = { lanPort: 8083, icon: '/assets/img/app-icons/file-browser.webp', }), - thunderhub: staticApp({ - id: 'thunderhub', - title: 'ThunderHub', - version: '0.13.31', - shortDesc: 'Lightning node management UI', - longDesc: 'Full Lightning node management — channels, payments, routing fees, and node health. Connect to your LND and manage everything from one dashboard.', - state: 'running', - lanPort: 3010, - }), fedimint: staticApp({ id: 'fedimint', title: 'Fedimint', @@ -907,9 +934,12 @@ app.get('/rpc/v1', (_req, res) => { .send(`JSON-RPC is available at /rpc/v1 for POST requests only.\nOpen the dashboard at http://localhost:${uiPort}/.\n`) }) +// DEMO runs on a testnet (signet) so visitors can play with worthless coins. +const DEMO_CHAIN = DEMO ? 'signet' : 'main' + function mockBitcoinBlockchainInfo() { return { - chain: 'main', + chain: DEMO_CHAIN, blocks: 902418, headers: 902418, bestblockhash: randomHex(32), @@ -948,7 +978,7 @@ function bitcoinRelayStatusPayload() { synced: true, blocks: 902418, headers: 902418, - chain: 'main', + chain: DEMO_CHAIN, status_ok: true, status_stale: false, error: null, @@ -974,14 +1004,121 @@ app.get('/app/bitcoin-ui/', async (_req, res) => { } }) -app.get('/app/bitcoin-ui/bitcoin-status', (_req, res) => { +// ── Mock app UIs served in the in-app iframe (DEMO) ───────────────────────── +function demoAppShell(title, sub, iconPath, bodyHtml) { + const accent = '#f7931a' // Archipelago orange + return ` +${title} +
+
+ +

${title}

${sub}
+
+ ${bodyHtml} +
Archipelago demo · signet
` +} + +// 12 trusted/federated nodes for the demo Federation view. +function demoFederationNodes() { + const names = [ + ['archy-198', 'trusted', ['bitcoin-knots', 'lnd', 'mempool', 'electrs']], + ['arch-tailscale-1', 'trusted', ['bitcoin-knots', 'lnd', 'nextcloud']], + ['bunker-alpha', 'trusted', ['bitcoin-knots', 'vaultwarden']], + ['seaside-node', 'trusted', ['bitcoin-knots', 'lnd', 'fedimint']], + ['highland-relay', 'trusted', ['bitcoin-knots', 'electrs', 'mempool']], + ['orchard-12', 'trusted', ['bitcoin-knots', 'immich']], + ['nimbus-vps', 'trusted', ['bitcoin-knots', 'lnd', 'thunderhub']], + ['rivertown', 'trusted', ['bitcoin-knots', 'jellyfin']], + ['granite-pi', 'trusted', ['bitcoin-knots', 'lnd']], + ['saltmarsh', 'observer', ['bitcoin-knots']], + ['dustbowl-node', 'observer', ['bitcoin-knots', 'electrs']], + ['frontier-7', 'observer', ['bitcoin-knots', 'mempool']], + ] + return names.map(([name, trust, apps], i) => { + const seenAgo = 60000 + i * 95000 + return { + did: `did:key:z6Mk${randomHex(20)}`, + pubkey: randomHex(32), + onion: `${name.replace(/[^a-z0-9]/g, '')}${randomHex(8)}.onion`, + trust_level: trust, + added_at: new Date(Date.now() - (i + 2) * 86400000).toISOString(), + name, + last_seen: new Date(Date.now() - seenAgo).toISOString(), + last_state: { + timestamp: new Date(Date.now() - seenAgo).toISOString(), + apps: apps.map(id => ({ id, status: 'running', version: '1.0' })), + cpu_usage_percent: Math.round((6 + (i * 7) % 55) * 10) / 10, + mem_used_bytes: (2 + (i % 6)) * 1_000_000_000, + mem_total_bytes: (i % 2 ? 32 : 16) * 1_000_000_000, + disk_used_bytes: (400 + i * 90) * 1_000_000_000, + disk_total_bytes: (i % 2 ? 2000 : 1000) * 1_000_000_000, + uptime_secs: 86400 * (i + 1), + tor_active: trust === 'trusted', + }, + } + }) +} + +// Serve each real registry UI's WHOLE directory at its /app// path, so the +// shell's bundled assets (qrcode.js, css, bg images, icons) resolve via their +// relative paths. The data endpoints each shell polls are mocked separately. +const DOCKER_UI = path.join(__dirname, '..', 'docker') +for (const [prefixes, dir] of [ + [['/app/bitcoin-core', '/app/bitcoin-knots', '/app/bitcoin-ui'], 'bitcoin-ui'], + [['/app/electrumx', '/app/electrs', '/app/archy-electrs-ui'], 'electrs-ui'], + [['/app/lnd', '/app/lnd-ui', '/app/archy-lnd-ui', '/app/thunderhub'], 'lnd-ui'], + [['/app/fedimint', '/app/fedimintd'], 'fedimint-ui'], +]) { + for (const p of prefixes) app.use(p, express.static(path.join(DOCKER_UI, dir))) +} +// Shells that reference /app//assets/... resolve to the frontend's shared assets. +app.get(/^\/app\/[^/]+\/assets\/(.*)$/, (req, res) => res.redirect(302, '/assets/' + req.params[0])) + +// Dummy status for both the electrs-ui shell and the in-app ElectrumX sync screen. +app.get('/electrs-status', (_req, res) => { res.json({ - ok: true, + status: 'ready', + synced: true, stale: false, - updated_at_ms: Date.now(), error: null, + bitcoin_height: 902418, + network_height: 902418, + indexed_height: 902418, + progress_pct: 100, + index_size: '58.2 GB', + tor_onion: 'electrumx' + randomHex(20) + '.onion', + }) +}) + +// ── Bitcoin Core + Knots: per-implementation status (shell served statically) ─ +function bitcoinStatusPayload(impl) { + const net = mockBitcoinNetworkInfo() + net.subversion = impl === 'knots' ? '/Satoshi:28.1.0/Knots:20250305/' : '/Satoshi:28.4.0/' + return { + ok: true, stale: false, updated_at_ms: Date.now(), error: null, blockchain_info: mockBitcoinBlockchainInfo(), - network_info: mockBitcoinNetworkInfo(), + network_info: net, index_info: { txindex: { synced: true, best_block_height: 902418 }, blockfilterindex: { synced: true, best_block_height: 902418 }, @@ -990,10 +1127,59 @@ app.get('/app/bitcoin-ui/bitcoin-status', (_req, res) => { { type: 'pubhashblock', address: 'tcp://127.0.0.1:28332', hwm: 1000 }, { type: 'pubrawtx', address: 'tcp://127.0.0.1:28333', hwm: 1000 }, ], - }) -}) + } +} +app.get('/app/bitcoin-core/bitcoin-status', (_req, res) => res.json(bitcoinStatusPayload('core'))) +app.get('/app/bitcoin-knots/bitcoin-status', (_req, res) => res.json(bitcoinStatusPayload('knots'))) -app.post('/app/bitcoin-ui/bitcoin-rpc/', (req, res) => { +// ── LND: the endpoints the lnd-ui shell polls (shell served statically) ────── +function lndGetinfo() { + return { + alias: 'archipelago-lnd', identity_pubkey: '02' + randomHex(32), + num_active_channels: 4, num_inactive_channels: 0, num_pending_channels: 0, num_peers: 11, + block_height: 902418, block_hash: randomHex(32), + synced_to_chain: true, synced_to_graph: true, version: '0.18.3-beta', + chains: [{ chain: 'bitcoin', network: 'signet' }], + } +} +function lndChannels() { + const peers = [['ACINQ', 5_000_000, 2_450_000], ['Wallet of Satoshi', 2_000_000, 1_200_000], + ['Voltage', 10_000_000, 4_500_000], ['Kraken', 3_000_000, 1_800_000]] + return { + channels: peers.map(([alias, cap, local]) => ({ + active: true, remote_pubkey: '02' + randomHex(32), peer_alias: alias, + capacity: String(cap), local_balance: String(local), remote_balance: String(cap - local), + total_satoshis_sent: '12840', total_satoshis_received: '9120', private: false, + })), + } +} +app.get('/proxy/lnd/v1/getinfo', (_req, res) => res.json(lndGetinfo())) +app.get('/proxy/lnd/v1/channels', (_req, res) => res.json(lndChannels())) +app.get('/lnd-connect-info', (_req, res) => res.json({ + getinfo: lndGetinfo(), channelCount: 4, cert_base: 'demo', + grpcReachable: true, restReachable: true, + tor_onion: 'lnd' + randomHex(16) + '.onion', error: null, +})) +// App catalog (the discover page tries this first, then /catalog.json). +app.get('/api/app-catalog', async (_req, res) => { + try { + const json = await fs.readFile(path.join(__dirname, 'public', 'catalog.json'), 'utf8') + res.type('application/json').send(json) + } catch { res.status(404).json({ error: 'not found' }) } +}) +app.get('/api/container/logs', (_req, res) => res.json({ + logs: [ + '[INF] LND: Version 0.18.3-beta commit=v0.18.3-beta', + '[INF] CHDB: Inserting 4 channel(s) into database', + '[INF] LND: Channel(s) active; synced_to_chain=true', + '[INF] PEER: Connected to 11 peers', + '[INF] RPCS: gRPC proxy started at 0.0.0.0:8080', + ].join('\n'), +})) + +app.get('/app/bitcoin-ui/bitcoin-status', (_req, res) => res.json(bitcoinStatusPayload('knots'))) + +app.post(['/app/bitcoin-ui/bitcoin-rpc/', '/app/bitcoin-core/bitcoin-rpc/', '/app/bitcoin-knots/bitcoin-rpc/'], (req, res) => { const { id = 'bitcoin-ui', method } = req.body || {} const results = { getblockchaininfo: mockBitcoinBlockchainInfo(), @@ -1015,7 +1201,7 @@ app.post('/app/bitcoin-ui/bitcoin-rpc/', (req, res) => { res.json({ id, result: results[method], error: null }) }) -app.post('/app/bitcoin-ui/rpc/v1', (req, res) => { +app.post(['/app/bitcoin-ui/rpc/v1', '/app/bitcoin-core/rpc/v1', '/app/bitcoin-knots/rpc/v1'], (req, res) => { const { method, params = {} } = req.body || {} const now = new Date().toISOString() switch (method) { @@ -1142,6 +1328,11 @@ app.post('/rpc/v1', (req, res) => { return res.json({ result: true }) } + case 'app.filebrowser-token': { + // The Cloud/Files UI exchanges this for a filebrowser auth cookie. + // The mock filebrowser endpoints don't validate it, so any token works. + return res.json({ result: { token: `demo-fb-${Date.now().toString(36)}` } }) + } case 'node.did': { const mockDid = 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH' const mockPubkey = 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456' @@ -1706,6 +1897,18 @@ app.post('/rpc/v1', (req, res) => { // ========================================================================= // Nostr // ========================================================================= + case 'nostr.list-relays': { + return res.json({ result: { relays: [ + { url: 'wss://relay.damus.io', connected: true, enabled: true, added_at: '2026-04-02T10:00:00Z' }, + { url: 'wss://nos.lol', connected: true, enabled: true, added_at: '2026-04-02T10:00:00Z' }, + { url: 'wss://relay.snort.social', connected: true, enabled: true, added_at: '2026-04-10T08:30:00Z' }, + { url: 'wss://nostr.wine', connected: false, enabled: true, added_at: '2026-05-01T14:00:00Z' }, + { url: 'wss://relay.archipelago.lan', connected: true, enabled: false, added_at: '2026-05-20T09:15:00Z' }, + ] } }) + } + case 'nostr.get-stats': { + return res.json({ result: { total_relays: 5, connected_count: 3, enabled_count: 4 } }) + } case 'nostr.add-relay': { const { url } = params || {} console.log(`[Nostr] Added relay: ${url}`) @@ -1720,6 +1923,36 @@ app.post('/rpc/v1', (req, res) => { return res.json({ result: { success: true } }) } + // VPN (WireGuard) status + case 'vpn.status': { + return res.json({ result: { + connected: true, + provider: 'wireguard', + interface: 'wg0', + ip_address: '10.64.12.7', + hostname: 'archipelago-demo', + peers_connected: 3, + bytes_in: 1_843_201_004, + bytes_out: 642_889_310, + } }) + } + + // Node visibility — interactive (persisted per demo session) + case 'network.get-visibility': { + return res.json({ result: { + visibility: mockState.nodeVisibility, + onion_address: mockData['server-info']['tor-address'], + } }) + } + case 'network.set-visibility': { + const v = params?.visibility + if (['hidden', 'discoverable', 'public'].includes(v)) mockState.nodeVisibility = v + return res.json({ result: { + visibility: mockState.nodeVisibility, + onion_address: mockData['server-info']['tor-address'], + } }) + } + // ========================================================================= // Content & Network // ========================================================================= @@ -1744,6 +1977,125 @@ app.post('/rpc/v1', (req, res) => { return res.json({ result: { success: true } }) } + // ── Demo paid-content / "buying files" flow ─────────────────────────── + // Every payment path resolves to success so visitors can experience the + // full buy → unlock journey with testnet addresses and invoices. + case 'content.owned-list': { + return res.json({ result: { items: [] } }) + } + case 'content.owned-get': + case 'content.preview-peer': + case 'content.download-peer-paid': + case 'content.download-peer-invoice': + case 'content.download-peer-onchain': { + const filename = params?.filename || 'demo-content' + const body = Buffer.from( + `Archipelago demo — "${filename}"\n\nThis is sample paid content delivered over the ` + + `node-to-node content market. On a real node this would be the actual file you purchased.\n`, + 'utf-8' + ).toString('base64') + return res.json({ result: { data: body, mime_type: 'text/plain', ecash_backend: params?.method || 'cashu' } }) + } + case 'content.request-invoice': { + const price = params?.price_sats || 500 + const payment_hash = randomHex(32) + invoiceRequestedAt.set(payment_hash, Date.now()) + return res.json({ + result: { + bolt11: 'lntb' + price + '1p' + randomHex(50), + payment_hash, + price_sats: price, + }, + }) + } + case 'content.invoice-status': { + // Demo: leave the QR on screen for ~2s before the invoice "settles". + const at = invoiceRequestedAt.get(params?.payment_hash) + const paid = !at || (Date.now() - at) >= 2000 + return res.json({ result: { paid } }) + } + + // ── Wallet settings: Cashu mints + Fedimint federations (interactive) ── + case 'streaming.list-mints': { + return res.json({ result: { mints: mockState.cashuMints } }) + } + case 'streaming.configure-mints': { + if (Array.isArray(params?.mints)) mockState.cashuMints = params.mints + return res.json({ result: { success: true, mints: mockState.cashuMints } }) + } + case 'wallet.fedimint-list': { + return res.json({ result: { federations: mockState.federations } }) + } + case 'wallet.fedimint-join': { + const fed = { federation_id: 'fed1' + randomHex(28), name: 'Joined Federation', balance_sats: 0 } + mockState.federations.push(fed) + return res.json({ result: { success: true, ...fed } }) + } + case 'wallet.fedimint-balance': { + const total = (mockState.federations || []).reduce((s, f) => s + (f.balance_sats || 0), 0) + return res.json({ result: { balance_sats: total } }) + } + case 'content.request-onchain': { + const price = params?.price_sats || 500 + return res.json({ + result: { + address: 'tb1q' + randomHex(19), + amount_sats: price, + content_id: params?.content_id || '', + expires_at: new Date(Date.now() + 3600000).toISOString(), + }, + }) + } + case 'content.onchain-status': { + return res.json({ result: { paid: true, status: 'confirmed', confirmations: 1 } }) + } + + // ── FIPS encrypted-mesh transport (shows as fully active in the demo) ── + case 'fips.status': { + return res.json({ + result: { + installed: true, + version: '0.4.2', + service_state: 'active', + upstream_service_state: 'active', + service_active: true, + key_present: true, + npub: 'npub1' + randomHex(28), + authenticated_peer_count: 5, + anchor_connected: true, + }, + }) + } + case 'fips.list-seed-anchors': { + return res.json({ + result: { + seed_anchors: [ + { npub: 'npub1' + randomHex(28), address: 'fips.v0l.io:8443', transport: 'tcp', label: 'Public anchor' }, + { npub: 'npub1' + randomHex(28), address: 'anchor.archipelago.lan:8443', transport: 'tcp', label: 'Federation anchor' }, + ], + }, + }) + } + case 'fips.add-seed-anchor': + case 'fips.remove-seed-anchor': { + const seed = [ + { npub: 'npub1' + randomHex(28), address: params?.address || 'fips.v0l.io:8443', transport: 'tcp', label: params?.label || 'Anchor' }, + ] + return res.json({ result: { seed_anchors: seed, apply: [{ npub: seed[0].npub, ok: true, message: 'applied' }] } }) + } + case 'fips.apply-seed-anchors': { + return res.json({ result: { applied: 2, results: [ + { npub: 'npub1' + randomHex(28), ok: true, message: 'connected' }, + { npub: 'npub1' + randomHex(28), ok: true, message: 'connected' }, + ] } }) + } + case 'fips.reconnect': { + return res.json({ result: { success: true, anchor_connected: true, authenticated_peer_count: 5 } }) + } + case 'fips.install': { + return res.json({ result: { success: true, installed: true, version: '0.4.2' } }) + } + case 'network.diagnostics': { return res.json({ result: { @@ -1989,85 +2341,9 @@ app.post('/rpc/v1', (req, res) => { // ===================================================================== // Federation (multi-node clusters) // ===================================================================== + case 'federation.list': case 'federation.list-nodes': { - return res.json({ - result: { - nodes: [ - { - did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9', - pubkey: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', - onion: 'peer1abc2def3ghi4jkl5mno6pqr7stu8vwx9yz.onion', - trust_level: 'trusted', - added_at: '2026-02-15T10:30:00Z', - name: 'archy-198', - last_seen: new Date(Date.now() - 120000).toISOString(), - last_state: { - timestamp: new Date(Date.now() - 120000).toISOString(), - apps: [ - { id: 'bitcoin-knots', status: 'running', version: '27.1' }, - { id: 'lnd', status: 'running', version: '0.18.0' }, - { id: 'mempool', status: 'running', version: '3.0' }, - { id: 'electrs', status: 'running', version: '0.10.0' }, - ], - cpu_usage_percent: 18.3, - mem_used_bytes: 6_200_000_000, - mem_total_bytes: 16_000_000_000, - disk_used_bytes: 820_000_000_000, - disk_total_bytes: 1_800_000_000_000, - uptime_secs: 604800, - tor_active: true, - }, - }, - { - did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH', - pubkey: 'f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5', - onion: 'peer2xyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba.onion', - trust_level: 'trusted', - added_at: '2026-03-01T14:00:00Z', - name: 'arch-tailscale-1', - last_seen: new Date(Date.now() - 300000).toISOString(), - last_state: { - timestamp: new Date(Date.now() - 300000).toISOString(), - apps: [ - { id: 'bitcoin-knots', status: 'running', version: '27.1' }, - { id: 'lnd', status: 'running', version: '0.18.0' }, - { id: 'nextcloud', status: 'running', version: '29.0' }, - ], - cpu_usage_percent: 42.1, - mem_used_bytes: 10_500_000_000, - mem_total_bytes: 32_000_000_000, - disk_used_bytes: 1_200_000_000_000, - disk_total_bytes: 2_000_000_000_000, - uptime_secs: 259200, - tor_active: true, - }, - }, - { - did: 'did:key:z6MkrHKPxJP6tvCvXMaJKZd3rRA2Y44tyftVhR8FDCMKGFjb', - pubkey: 'c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4', - onion: 'peer3mno6pqr7stu8vwx9yzabc2def3ghi4jkl5.onion', - trust_level: 'observer', - added_at: '2026-03-10T09:15:00Z', - name: 'bunker-alpha', - last_seen: new Date(Date.now() - 3600000).toISOString(), - last_state: { - timestamp: new Date(Date.now() - 3600000).toISOString(), - apps: [ - { id: 'bitcoin-knots', status: 'running', version: '27.1' }, - { id: 'vaultwarden', status: 'running', version: '1.31.0' }, - ], - cpu_usage_percent: 5.8, - mem_used_bytes: 2_100_000_000, - mem_total_bytes: 8_000_000_000, - disk_used_bytes: 450_000_000_000, - disk_total_bytes: 1_000_000_000_000, - uptime_secs: 1209600, - tor_active: false, - }, - }, - ], - }, - }) + return res.json({ result: { nodes: demoFederationNodes() } }) } case 'federation.invite': { @@ -2783,11 +3059,16 @@ app.post('/rpc/v1', (req, res) => { case 'wallet.networking-profits': { return res.json({ result: { - total_earned_sats: 42, - total_forwarded_sats: 35025, - forward_count: 3, + total_sats: 5_231_978, + content_sales_sats: 3_180_000, + routing_fees_sats: 1_281_978, + relay_sats: 770_000, + // legacy aliases kept for older UI builds + total_earned_sats: 5_231_978, + total_forwarded_sats: 1_281_978, + forward_count: 1284, period_days: 30, - daily_avg_sats: 1.4, + daily_avg_sats: 174_399, }, }) } @@ -3012,10 +3293,10 @@ app.post('/rpc/v1', (req, res) => { case 'update.status': { return res.json({ result: { - current_version: '0.1.0', - latest_version: '0.1.1', - update_available: true, - release_notes: 'Bug fixes and performance improvements.', + current_version: APP_VERSION, + latest_version: APP_VERSION, + update_available: false, + release_notes: 'You are running the latest demo build.', channel: 'stable', }, }) @@ -3251,7 +3532,7 @@ app.post('/rpc/v1', (req, res) => { // ============================================================================= // Mock FileBrowser API (for Cloud page in demo/Docker deployments) // ============================================================================= -const MOCK_FILES = { +const SEED_FILES = { '/': [ { name: 'Music', path: '/Music', size: 0, modified: '2025-03-01T10:00:00Z', isDir: true, type: '' }, { name: 'Documents', path: '/Documents', size: 0, modified: '2025-02-28T14:30:00Z', isDir: true, type: '' }, @@ -3298,7 +3579,7 @@ const MOCK_FILES = { ], } -const MOCK_FILE_CONTENTS = { +const SEED_FILE_CONTENTS = { '/Documents/bitcoin-whitepaper-notes.md': `# Bitcoin Whitepaper Notes\n\n## Key Concepts\n\n### Peer-to-Peer Electronic Cash\n- No trusted third party needed\n- Double-spending solved via proof-of-work\n- Longest chain = truth\n\n### Proof of Work\n- SHA-256 based hashing\n- Difficulty adjusts every 2016 blocks (~2 weeks)\n- Incentive: block reward + transaction fees\n\n## My Thoughts\n- The 21M supply cap is genius - digital scarcity\n- Lightning Network solves the scaling concern\n- Self-custody is the whole point`, '/Documents/node-setup-checklist.md': `# Archipelago Node Setup Checklist\n\n## Hardware\n- [x] Intel NUC / Mini PC (16GB RAM minimum)\n- [x] 2TB NVMe SSD\n- [x] USB drive for installer\n- [x] Ethernet cable\n\n## Core Apps\n- [x] Bitcoin Knots\n- [x] LND\n- [x] Mempool Explorer\n- [ ] BTCPay Server\n- [ ] Fedimint`, '/Documents/lightning-channels.csv': `channel_id,peer_alias,capacity_sats,local_balance,remote_balance,status\nch_001,ACINQ,5000000,2450000,2550000,active\nch_002,WalletOfSatoshi,2000000,1200000,800000,active\nch_003,Voltage,10000000,4500000,5500000,active\nch_004,Kraken,3000000,1800000,1200000,active`, @@ -3306,6 +3587,63 @@ const MOCK_FILE_CONTENTS = { '/Documents/backup-log.json': JSON.stringify({ backups: [{ id: 'bkp-2025-03-01', timestamp: '2025-03-01T02:00:00Z', type: 'full', apps: ['bitcoin-knots', 'lnd', 'mempool'], size_mb: 2340, status: 'success' }] }, null, 2), } +// Curated real files: drop files into /demo/files// and they +// replace the seeded cloud content for every visitor (read-only — visitors can +// view/download/buy them but only maintainers add them, via git = the "private +// login"). If the folder is absent/empty the hardcoded seeds above are kept. +// Binary files are streamed from disk on demand (diskFilePaths); text is inlined. +const diskFilePaths = {} +function loadDemoDiskFiles() { + const root = path.join(__dirname, '..', 'demo', 'files') + let top + try { top = fsSync.readdirSync(root, { withFileTypes: true }) } catch { return } + const tree = { '/': [] } + const contents = {} + const TEXT_EXT = new Set(['txt', 'md', 'json', 'csv', 'log', 'yaml', 'yml', 'xml', 'conf', 'ini']) + const walk = (absDir, relDir) => { + let entries + try { entries = fsSync.readdirSync(absDir, { withFileTypes: true }) } catch { return } + tree[relDir] = tree[relDir] || [] + for (const e of entries) { + if (e.name.startsWith('.')) continue + const abs = path.join(absDir, e.name) + const rel = relDir === '/' ? `/${e.name}` : `${relDir}/${e.name}` + if (e.isDirectory()) { + tree[relDir].push({ name: e.name, path: rel, size: 0, modified: new Date().toISOString(), isDir: true, type: '' }) + walk(abs, rel) + } else { + let st; try { st = fsSync.statSync(abs) } catch { continue } + const ext = (e.name.includes('.') ? e.name.split('.').pop() : '').toLowerCase() + const type = fbType(e.name) + tree[relDir].push({ name: e.name, path: rel, size: st.size, modified: st.mtime.toISOString(), isDir: false, type }) + if (TEXT_EXT.has(ext) && st.size < 1_000_000) { + try { contents[rel] = fsSync.readFileSync(abs, 'utf-8') } catch { /* skip */ } + } else { + diskFilePaths[rel] = abs // streamed from disk by the raw handler + } + } + } + } + walk(root, '/') + if (!tree['/'].length) return // empty folder → keep the hardcoded seeds + // Per-folder MERGE: a top-level folder present in demo/files replaces that + // seed folder; seed folders the curator didn't provide (e.g. sample Documents) + // are kept. So dropping real Music/ doesn't wipe the sample Documents. + const provided = new Set(tree['/'].map(e => e.name)) + for (const k of Object.keys(SEED_FILES)) { + if (k !== '/' && provided.has(k.split('/')[1])) delete SEED_FILES[k] + } + for (const k of Object.keys(SEED_FILE_CONTENTS)) { + if (provided.has(k.split('/')[1])) delete SEED_FILE_CONTENTS[k] + } + const keptRoot = (SEED_FILES['/'] || []).filter(e => !provided.has(e.name)) + for (const [k, v] of Object.entries(tree)) { if (k !== '/') SEED_FILES[k] = v } + SEED_FILES['/'] = [...keptRoot, ...tree['/']] + Object.assign(SEED_FILE_CONTENTS, contents) + console.log(`[Demo] Merged curated files from demo/files (${Object.keys(diskFilePaths).length} binary, ${Object.keys(contents).length} text; folders: ${[...provided].join(', ')})`) +} +loadDemoDiskFiles() + // FileBrowser UI (demo placeholder when launched directly) app.get('/app/filebrowser/', (req, res) => { res.type('html').send(` @@ -3320,60 +3658,230 @@ app.post('/app/filebrowser/api/login', (req, res) => { res.send('"mock-filebrowser-token-demo"') }) -// FileBrowser list resources -app.get('/app/filebrowser/api/resources/*', (req, res) => { - const reqPath = decodeURIComponent(req.params[0] || '/').replace(/\/+$/, '') || '/' - const items = MOCK_FILES[reqPath] || [] +// ── Per-session file store helpers ────────────────────────────────────────── +// store().files = { tree: { '': [entries] }, contents: { '': string|Buffer }, bytes } +const FB_QUOTA_BYTES = Number(process.env.DEMO_FILE_QUOTA_BYTES) || 50 * 1024 * 1024 + +function fbNormalize(raw) { + // → leading slash, no trailing slash (root stays '/') + const p = '/' + decodeURIComponent(raw || '').split('/').filter(Boolean).join('/') + return p === '/' ? '/' : p.replace(/\/+$/, '') +} +function fbParent(p) { + const i = p.lastIndexOf('/') + return i <= 0 ? '/' : p.slice(0, i) +} +function fbBase(p) { return p.slice(p.lastIndexOf('/') + 1) } +function fbType(name) { + const ext = (name.includes('.') ? name.split('.').pop() : '').toLowerCase() + if (['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac'].includes(ext)) return 'audio' + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) return 'image' + if (['mp4', 'webm', 'mov', 'mkv', 'avi'].includes(ext)) return 'video' + if (['txt', 'md', 'json', 'csv', 'log', 'yaml', 'yml', 'xml', 'conf', 'ini'].includes(ext)) return 'text' + return '' +} +function fbContentType(name) { + const t = fbType(name) + const ext = (name.includes('.') ? name.split('.').pop() : '').toLowerCase() + if (t === 'audio') return ext === 'wav' ? 'audio/wav' : 'audio/mpeg' + if (t === 'image') return ext === 'png' ? 'image/png' : ext === 'svg' ? 'image/svg+xml' : 'image/jpeg' + if (t === 'video') return 'video/mp4' + return 'text/plain; charset=utf-8' +} +function fbListResponse(res, items) { res.json({ items, numDirs: items.filter(i => i.isDir).length, numFiles: items.filter(i => !i.isDir).length, sorting: { by: 'name', asc: true }, }) +} + +// FileBrowser list resources (root: /api/resources or /api/resources/) +app.get(['/app/filebrowser/api/resources', '/app/filebrowser/api/resources/*'], (req, res) => { + const dir = fbNormalize(req.params[0] || '') + const items = currentStore().files.tree[dir] || [] + fbListResponse(res, items) }) -app.get('/app/filebrowser/api/resources', (req, res) => { - const items = MOCK_FILES['/'] || [] - res.json({ - items, - numDirs: items.filter(i => i.isDir).length, - numFiles: items.filter(i => !i.isDir).length, - sorting: { by: 'name', asc: true }, - }) -}) - -// FileBrowser upload (POST to resources path) — mock accepts and discards the body +// FileBrowser POST = upload a file OR create a folder (trailing slash ⇒ folder) app.post('/app/filebrowser/api/resources/*', (req, res) => { - req.resume() - req.on('end', () => res.sendStatus(200)) -}) + const store = currentStore() + const { tree, contents } = store.files + const isFolder = (req.params[0] || '').endsWith('/') + const full = fbNormalize(req.params[0] || '') + const parent = fbParent(full) + const name = fbBase(full) + if (!name) return res.sendStatus(400) + if (!tree[parent]) tree[parent] = [] -// FileBrowser delete -app.delete('/app/filebrowser/api/resources/*', (req, res) => { - res.sendStatus(200) -}) - -// FileBrowser rename -app.patch('/app/filebrowser/api/resources/*', (req, res) => { - res.sendStatus(200) -}) - -// FileBrowser raw file content (for text file reading) -app.get('/app/filebrowser/api/raw/*', (req, res) => { - const reqPath = '/' + decodeURIComponent(req.params[0] || '') - const content = MOCK_FILE_CONTENTS[reqPath] - if (content) { - res.type('text/plain').send(content) - } else { - res.status(404).send('File not found') + if (isFolder) { + if (!tree[parent].some(e => e.name === name && e.isDir)) { + tree[parent].push({ name, path: full, size: 0, modified: new Date().toISOString(), isDir: true, type: '' }) + if (!tree[full]) tree[full] = [] + } + return res.sendStatus(200) } + + // File upload — collect body with a quota guard. + const chunks = [] + let size = 0 + let aborted = false + req.on('data', (c) => { + size += c.length + if (store.files.bytes + size > FB_QUOTA_BYTES) { + aborted = true + req.destroy() + return + } + chunks.push(c) + }) + req.on('end', () => { + if (aborted) return res.status(507).send('Demo storage quota exceeded (50 MB)') + const buf = Buffer.concat(chunks) + // Replace existing entry of the same name (override=true). + const existing = tree[parent].find(e => e.name === name && !e.isDir) + if (existing) store.files.bytes -= existing.size + tree[parent] = tree[parent].filter(e => !(e.name === name && !e.isDir)) + const type = fbType(name) + tree[parent].push({ name, path: full, size: buf.length, modified: new Date().toISOString(), isDir: false, type }) + contents[full] = type === 'text' ? buf.toString('utf-8') : buf + store.files.bytes += buf.length + res.sendStatus(200) + }) + req.on('error', () => { if (!res.headersSent) res.sendStatus(400) }) }) +// FileBrowser delete (file or folder + its subtree) +app.delete('/app/filebrowser/api/resources/*', (req, res) => { + const store = currentStore() + const { tree, contents } = store.files + const full = fbNormalize(req.params[0] || '') + const parent = fbParent(full) + if (tree[parent]) { + const entry = tree[parent].find(e => e.path === full) + if (entry && !entry.isDir) store.files.bytes -= entry.size || 0 + tree[parent] = tree[parent].filter(e => e.path !== full) + } + // Recursively drop a directory's children. + if (tree[full]) { + const stack = [full] + while (stack.length) { + const d = stack.pop() + for (const e of tree[d] || []) { + if (e.isDir) stack.push(e.path) + else { store.files.bytes -= e.size || 0; delete contents[e.path] } + } + delete tree[d] + } + } + delete contents[full] + res.sendStatus(200) +}) + +// FileBrowser rename/move (PATCH with { destination }) +app.patch('/app/filebrowser/api/resources/*', (req, res) => { + const store = currentStore() + const { tree, contents } = store.files + const full = fbNormalize(req.params[0] || '') + const dest = fbNormalize((req.body && req.body.destination) || '') + if (!dest || dest === '/') return res.sendStatus(400) + const parent = fbParent(full) + const entry = (tree[parent] || []).find(e => e.path === full) + if (!entry) return res.sendStatus(404) + const newName = fbBase(dest) + entry.name = newName + entry.path = dest + entry.modified = new Date().toISOString() + entry.type = entry.isDir ? '' : fbType(newName) + if (contents[full] !== undefined) { contents[dest] = contents[full]; delete contents[full] } + res.sendStatus(200) +}) + +// FileBrowser raw file content (text reads, blob/stream fetches) +app.get('/app/filebrowser/api/raw/*', (req, res) => { + const full = fbNormalize(req.params[0] || '') + // Curated binary files live on disk and stream directly, with HTTP Range + // support so audio/video can seek and play in the browser. + if (diskFilePaths[full]) { + const abs = diskFilePaths[full] + let stat + try { stat = fsSync.statSync(abs) } catch { return res.status(404).send('File not found') } + res.type(fbContentType(fbBase(full))) + res.setHeader('Accept-Ranges', 'bytes') + const range = req.headers.range + if (range) { + const m = /bytes=(\d*)-(\d*)/.exec(range) + let start = m && m[1] ? parseInt(m[1], 10) : 0 + let end = m && m[2] ? parseInt(m[2], 10) : stat.size - 1 + if (isNaN(start) || start < 0) start = 0 + if (isNaN(end) || end >= stat.size) end = stat.size - 1 + if (start > end) { res.status(416).setHeader('Content-Range', `bytes */${stat.size}`); return res.end() } + res.status(206) + res.setHeader('Content-Range', `bytes ${start}-${end}/${stat.size}`) + res.setHeader('Content-Length', end - start + 1) + return fsSync.createReadStream(abs, { start, end }) + .on('error', () => { if (!res.headersSent) res.status(404).end() }).pipe(res) + } + res.setHeader('Content-Length', stat.size) + return fsSync.createReadStream(abs) + .on('error', () => { if (!res.headersSent) res.status(404).send('File not found') }).pipe(res) + } + const content = currentStore().files.contents[full] + if (content === undefined) return res.status(404).send('File not found') + res.type(fbContentType(fbBase(full))) + res.send(Buffer.isBuffer(content) ? content : String(content)) +}) + +// A compact description of the current (mock) node, fed to the AI assistant as +// system context in the demo so it can answer questions about this node. +function demoNodeContext() { + const s = currentStore() + const md = s.mockData + const w = s.walletState + const apps = Object.entries(md['package-data'] || {}) + .map(([id, a]) => `${a.title || id} (${a.state || 'running'})`) + return [ + 'You are the AI assistant built into this Archipelago node. This is a public DEMO node running on Bitcoin signet (testnet) with simulated data — answer as if everything is real, be concise and helpful, and feel free to discuss Bitcoin, Lightning, self-hosting and the node itself.', + '', + 'CURRENT NODE STATE:', + `- Software: Archipelago ${md['server-info']?.version || 'demo'}, Tor address ${md['server-info']?.['tor-address'] || 'n/a'}`, + `- Bitcoin: signet, block height 902,418, fully synced (Bitcoin Knots).`, + `- Wallet: on-chain ${w.onchain_sats.toLocaleString()} sats, Lightning ${w.channel_sats.toLocaleString()} sats, ecash ${w.ecash_sats.toLocaleString()} sats.`, + `- Installed apps (${apps.length}): ${apps.join(', ')}.`, + `- Network: FIPS encrypted mesh active with 5 authenticated peers; 12 trusted/federated nodes; Tor hidden services online.`, + '- The user can install apps, manage their Lightning/ecash wallet, browse & buy peer files, and chat with the mesh — all from this dashboard.', + ].join('\n') +} + // Claude API Proxy (reads ANTHROPIC_API_KEY from environment) // Uses fetch (Node 22+) for reliable DNS resolution and streaming in Docker/Alpine // ============================================================================= app.post('/aiui/api/claude/*', async (req, res) => { + // DEMO: don't call the real model — return a fixed message (also avoids + // spending the shared API key). Replies in the Anthropic streaming or + // non-streaming shape depending on what the client asked for. + if (DEMO) { + const MSG = 'Not available in demo, check out the previous chats to experience AIUI' + if (req.body && req.body.stream === false) { + return res.json({ + id: 'msg_demo', type: 'message', role: 'assistant', model: 'claude-demo', + content: [{ type: 'text', text: MSG }], + stop_reason: 'end_turn', stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 14 }, + }) + } + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }) + const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + send('message_start', { type: 'message_start', message: { id: 'msg_demo', type: 'message', role: 'assistant', model: 'claude-demo', content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 } } }) + send('content_block_start', { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }) + send('content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: MSG } }) + send('content_block_stop', { type: 'content_block_stop', index: 0 }) + send('message_delta', { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 14 } }) + send('message_stop', { type: 'message_stop' }) + return res.end() + } const apiKey = process.env.ANTHROPIC_API_KEY if (!apiKey) { return res.status(500).json({ @@ -3402,6 +3910,16 @@ app.post('/aiui/api/claude/*', async (req, res) => { delete body.webSearch delete body.webResults delete body.context + + // DEMO: ground the assistant in THIS node's (mock) state so it answers + // questions about the local node, its apps, wallet and Bitcoin like a real + // Archipelago assistant — no /seed needed. + if (DEMO) { + const ctx = demoNodeContext() + if (typeof body.system === 'string') body.system = ctx + '\n\n' + body.system + else if (Array.isArray(body.system)) body.system = [{ type: 'text', text: ctx }, ...body.system] + else body.system = ctx + } } const bodyStr = JSON.stringify(body) @@ -3713,18 +4231,166 @@ app.get('/app/thunderhub/api/invoices', (req, res) => res.json(MOCK_LND_DATA.inv app.get('/app/thunderhub/api/payments', (req, res) => res.json(MOCK_LND_DATA.payments)) app.get('/app/thunderhub/api/forwards', (req, res) => res.json(MOCK_LND_DATA.forwarding)) +// Generic app shell for any launched app without a dedicated mock UI — a clean +// "not interactive in the demo" notice with the app's icon. Registered after all +// specific /app/... routes so those win; only bare /app/[/] reaches here. +app.get(['/app/:id', '/app/:id/'], (req, res) => { + const id = String(req.params.id || '').replace(/[^a-z0-9._-]/gi, '') + const title = id.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) + res.type('html').send(` +${title} +
+ +

${title}

+

Not available in the demo.
This app runs fully on a real Archipelago node.

+
Demo
+
`) +}) + // Health check app.get('/health', (req, res) => { res.status(200).send('healthy') }) +// ─────────────────────────────────────────────────────────────────────────── +// Per-session state isolation (DEMO multi-visitor sandbox) +// +// Every mutable global (mockData, walletState, userState, mockState, +// bitcoinRelayMockState) and the filebrowser file store is partitioned per +// visitor. Handlers keep referring to those names unchanged — the names are +// Proxies that forward to the current request's store, resolved via +// AsyncLocalStorage. Outside DEMO (or outside a request, e.g. at startup) they +// resolve to a single shared `defaultStore`, so classic single-user behaviour +// is byte-for-byte preserved. +// ─────────────────────────────────────────────────────────────────────────── +const stateContext = new AsyncLocalStorage() + +// Build a fresh, fully-isolated state bundle from the pristine seeds. +function makeSessionStore() { + const md = structuredClone(SEED_MOCKDATA) + // No real runtime in DEMO → package list is the curated static app set. + md['package-data'] = structuredClone(staticDevApps) + return { + mockData: md, + walletState: structuredClone(SEED_WALLET), + userState: seedUserState(), + mockState: seedMockState(), + bitcoinRelayMockState: structuredClone(SEED_BTCRELAY), + files: { tree: structuredClone(SEED_FILES), contents: structuredClone(SEED_FILE_CONTENTS), bytes: 0 }, + sockets: new Set(), + lastSeen: Date.now(), + } +} + +// The shared store used in single-user mode and for any work outside a request. +const defaultStore = makeSessionStore() + +function currentStore() { + return stateContext.getStore() || defaultStore +} + +// A Proxy whose every operation is delegated to currentStore()[bucket], so the +// existing handler code (`mockData['package-data']`, `walletState.x += n`, …) +// transparently reads/writes the right visitor's state. +function sessionBucketProxy(bucket) { + const target = () => currentStore()[bucket] + return new Proxy(Object.create(null), { + get: (_t, k) => target()[k], + set: (_t, k, v) => { target()[k] = v; return true }, + has: (_t, k) => k in target(), + deleteProperty: (_t, k) => { delete target()[k]; return true }, + ownKeys: () => Reflect.ownKeys(target()), + getOwnPropertyDescriptor: (_t, k) => { + const d = Object.getOwnPropertyDescriptor(target(), k) + if (d) d.configurable = true + return d + }, + defineProperty: (_t, k, d) => { Object.defineProperty(target(), k, d); return true }, + getPrototypeOf: () => Object.prototype, + }) +} + +const mockData = sessionBucketProxy('mockData') +const walletState = sessionBucketProxy('walletState') +const userState = sessionBucketProxy('userState') +const mockState = sessionBucketProxy('mockState') +const bitcoinRelayMockState = sessionBucketProxy('bitcoinRelayMockState') + +// Demo session lifecycle: keyed by the `demo_sid` cookie, capped, idle-reaped. +const demoSessions = new Map() // sid -> store +const DEMO_SESSION_TTL_MS = Number(process.env.DEMO_SESSION_TTL_MS) || 45 * 60 * 1000 +const DEMO_MAX_SESSIONS = Number(process.env.DEMO_MAX_SESSIONS) || 500 + +function resolveSessionStore(req, res) { + let sid = req.cookies?.demo_sid + if (!sid || !demoSessions.has(sid)) { + // Cap concurrent sessions: evict the oldest if we're at the limit. + if (demoSessions.size >= DEMO_MAX_SESSIONS) { + let oldestSid = null, oldest = Infinity + for (const [k, s] of demoSessions) if (s.lastSeen < oldest) { oldest = s.lastSeen; oldestSid = k } + if (oldestSid) { reapSession(oldestSid) } + } + sid = crypto.randomUUID() + demoSessions.set(sid, makeSessionStore()) + res.cookie('demo_sid', sid, { httpOnly: true, sameSite: 'lax', maxAge: DEMO_SESSION_TTL_MS }) + } + const store = demoSessions.get(sid) + store.lastSeen = Date.now() + store.sid = sid + return store +} + +// Resolve the session store for a WebSocket upgrade request. The HTTP layer has +// already issued the `demo_sid` cookie by the time the socket connects; if it is +// somehow absent we fall back to a fresh ephemeral store (no cookie to set here). +function wsStoreForRequest(req) { + const raw = req.headers?.cookie || '' + const m = raw.match(/(?:^|;\s*)demo_sid=([^;]+)/) + const sid = m && m[1] + if (sid && demoSessions.has(sid)) { + const store = demoSessions.get(sid) + store.lastSeen = Date.now() + return store + } + const store = makeSessionStore() + if (sid) demoSessions.set(sid, store) + return store +} + +function reapSession(sid) { + const store = demoSessions.get(sid) + if (!store) return + for (const ws of store.sockets) { try { ws.close(4000, 'session expired') } catch { /* ignore */ } } + demoSessions.delete(sid) +} + +if (DEMO) { + setInterval(() => { + const now = Date.now() + for (const [sid, store] of demoSessions) { + if (now - store.lastSeen > DEMO_SESSION_TTL_MS) reapSession(sid) + } + }, 60 * 1000).unref?.() +} + // WebSocket endpoint const server = http.createServer(app) const wss = new WebSocketServer({ server, path: '/ws/db' }) wss.on('connection', (ws, req) => { console.log('[WebSocket] Client connected from', req.socket.remoteAddress) - wsClients.add(ws) + // Attach this socket to the visitor's session store so broadcasts only reach + // that visitor. In non-DEMO mode every socket joins the single default store. + const wsStore = DEMO ? wsStoreForRequest(req) : defaultStore + wsStore.sockets.add(ws) // Set up ping/pong to keep connection alive const pingInterval = setInterval(() => { @@ -3751,11 +4417,12 @@ wss.on('connection', (ws, req) => { } }, 45000) // Every 45s (client expects data within 60s) - // Send initial data immediately + // Send initial data immediately (this visitor's store, not the global proxy — + // there is no request context inside the WS connection handler). try { ws.send(JSON.stringify({ type: 'initial', - data: mockData, + data: wsStore.mockData, })) console.log('[WebSocket] Initial data sent') } catch (err) { @@ -3780,23 +4447,45 @@ wss.on('connection', (ws, req) => { console.log('[WebSocket] Client disconnected', { code, reason: reason.toString() }) clearInterval(pingInterval) clearInterval(heartbeatInterval) - wsClients.delete(ws) + wsStore.sockets.delete(ws) }) ws.on('error', (error) => { console.error('[WebSocket Error]', error) clearInterval(pingInterval) clearInterval(heartbeatInterval) - wsClients.delete(ws) + wsStore.sockets.delete(ws) }) }) +// Best-effort: pull a few REAL recent testnet txids so the wallet's transactions +// deep-link to live mempool.space/testnet pages. Falls back to the mock hashes +// (already set) if offline. Patches the pristine seed so every session inherits them. +async function hydrateRealTestnetTxids() { + if (!DEMO) return + try { + const ctrl = new AbortController() + const t = setTimeout(() => ctrl.abort(), 4000) + const r = await fetch('https://mempool.space/testnet/api/mempool/recent', { signal: ctrl.signal }) + clearTimeout(t) + if (!r.ok) return + const recent = await r.json() + const txids = (Array.isArray(recent) ? recent : []).map(x => x.txid).filter(Boolean) + if (!txids.length) return + SEED_WALLET.transactions.forEach((tx, i) => { if (txids[i]) tx.tx_hash = txids[i] }) + // Also patch the already-built default store (sessions clone from SEED at creation). + defaultStore.walletState.transactions.forEach((tx, i) => { if (txids[i]) tx.tx_hash = txids[i] }) + console.log(`[Demo] Hydrated ${Math.min(txids.length, SEED_WALLET.transactions.length)} real testnet txids`) + } catch { /* offline — keep mock hashes */ } +} + server.listen(PORT, '0.0.0.0', async () => { const runtime = await isContainerRuntimeAvailable() - + // Initialize package data from Docker await initializePackageData() - + await hydrateRealTestnetTxids() + console.log(` ╔════════════════════════════════════════════════════════════╗ ║ ║ diff --git a/neode-ui/public/catalog.json b/neode-ui/public/catalog.json index b2ca489b..24b358de 100644 --- a/neode-ui/public/catalog.json +++ b/neode-ui/public/catalog.json @@ -195,7 +195,7 @@ "title": "Nostr Relay (Rust)", "version": "0.8.0", "description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.", - "icon": "/assets/img/app-icons/nostr.svg", + "icon": "/assets/img/app-icons/nostrudel.svg", "author": "Nostr RS Relay", "category": "community", "tier": "recommended", diff --git a/neode-ui/src/composables/useDemoIntro.ts b/neode-ui/src/composables/useDemoIntro.ts new file mode 100644 index 00000000..38fee9a4 --- /dev/null +++ b/neode-ui/src/composables/useDemoIntro.ts @@ -0,0 +1,96 @@ +/** + * Public-demo helpers. + * + * The demo build (VITE_DEMO=1) replays the intro/onboarding on each visit, but + * only once per calendar day per browser — tracked in localStorage so it + * survives the short-lived backend session. Also exposes the shared demo + * credentials shown on the login screen. + */ + +export const IS_DEMO = + import.meta.env.VITE_DEMO === '1' || import.meta.env.VITE_DEMO === 'true' + +/** Memorable shared password for the public demo (must match the mock backend). */ +export const DEMO_PASSWORD = 'entertoexit' + +const INTRO_DATE_KEY = 'demo_intro_date' + +function todayKey(): string { + // Local calendar day, e.g. "2026-06-22". + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +/** True if this browser already watched the intro earlier today. */ +export function demoIntroSeenToday(): boolean { + try { + return localStorage.getItem(INTRO_DATE_KEY) === todayKey() + } catch { + return false + } +} + +/** Record that the intro has been seen today, so it won't replay until tomorrow. */ +export function markDemoIntroSeen(): void { + try { + localStorage.setItem(INTRO_DATE_KEY, todayKey()) + } catch { + /* ignore (private mode / storage disabled) */ + } +} + +/** Forget today's "seen" marker so the intro plays again (e.g. "Replay Intro"). */ +export function clearDemoIntroSeen(): void { + try { + localStorage.removeItem(INTRO_DATE_KEY) + } catch { + /* ignore */ + } +} + +// ── Demoable apps ─────────────────────────────────────────────────────────── +// Only these apps actually do something in the demo (a mock UI or a real +// external site). Everything else shows "No demo" on a disabled install button +// and is not launchable. +const DEMO_EXTERNAL_URLS: Record = {} + +// Apps loaded in the in-app iframe via a same-origin path. IndeeHub and Mempool +// are reverse-proxied by nginx (X-Frame-Options/CSP stripped + asset paths +// rewritten) so the frame-busting real sites can be embedded. +const DEMO_MOCK_UI: Record = { + indeedhub: '/app/indeedhub/', + mempool: '/app/mempool/', + 'mempool-web': '/app/mempool/', + 'bitcoin-knots': '/app/bitcoin-knots/', + 'bitcoin-core': '/app/bitcoin-core/', + bitcoin: '/app/bitcoin-core/', + 'bitcoin-ui': '/app/bitcoin-ui/', + electrs: '/app/electrumx/', + electrumx: '/app/electrumx/', + 'archy-electrs-ui': '/app/electrumx/', + lnd: '/app/lnd/', + 'lnd-ui': '/app/lnd/', + 'archy-lnd-ui': '/app/lnd/', + thunderhub: '/app/lnd/', + fedimint: '/app/fedimint/', + fedimintd: '/app/fedimint/', + filebrowser: '/app/filebrowser/', +} + +/** + * Whether a demo app opens in a new tab. Nothing does — IndeeHub and Mempool + * both load their real site directly in the in-app iframe. + */ +export function isDemoExternal(_appId: string): boolean { + return false +} + +/** Can this app be launched/installed in the demo? */ +export function isDemoApp(appId: string): boolean { + return appId in DEMO_EXTERNAL_URLS || appId in DEMO_MOCK_UI +} + +/** Resolve the demo launch URL for an app, or null if it isn't demoable. */ +export function demoAppUrl(appId: string): string | null { + return DEMO_EXTERNAL_URLS[appId] ?? DEMO_MOCK_UI[appId] ?? null +} diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index b14d7bb5..a0bb146f 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -5,6 +5,7 @@ import router from '@/router' import { recordAppLaunch } from '@/utils/appUsage' import { requestExternalOpen } from '@/api/remote-relay' import { openInAppOrNewTab } from '@/utils/openExternal' +import { IS_DEMO, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro' /** * Open a URL in a new browser tab — but if a companion (phone) is currently @@ -224,6 +225,17 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { recordAppLaunch(appId) const mobile = isMobileViewport() + // Demo: apps backed by a real external site that blocks iframing + // (mempool.space) open externally; everything else demoable renders in the + // in-app session. + if (IS_DEMO && isDemoExternal(appId)) { + const ext = demoAppUrl(appId) + if (ext) { + if (mobile) openInAppOrNewTab(ext) + else openExternal(ext) + return + } + } // Tab-only apps (set X-Frame-Options, can't be iframed). No interstitial: // desktop opens a new browser tab; mobile opens the in-app WebView (Android // companion) or a new browser tab (PWA) — see openInAppOrNewTab. diff --git a/neode-ui/src/views/AppSession.vue b/neode-ui/src/views/AppSession.vue index 843cd817..2535398e 100644 --- a/neode-ui/src/views/AppSession.vue +++ b/neode-ui/src/views/AppSession.vue @@ -110,6 +110,7 @@ import { useAppIdentity } from './appSession/useAppIdentity' import { useNostrBridge } from './appSession/useNostrBridge' import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal' import { useElectrsSync } from '@/composables/useElectrsSync' +import { IS_DEMO, isDemoExternal } from '@/composables/useDemoIntro' const props = defineProps<{ appIdProp?: string @@ -166,7 +167,11 @@ const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value == // viewport (and match the CSS `md` breakpoint) instead of a stale one-shot read. const isMobile = ref(typeof window !== 'undefined' && window.innerWidth < 768) function updateIsMobile() { isMobile.value = window.innerWidth < 768 } -const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value)) +// In the demo, apps backed by a real external site that blocks iframing +// (mempool.space) open in a new tab rather than the in-app session frame. +const mustOpenNewTab = computed(() => + NEW_TAB_APPS.has(appId.value) || (IS_DEMO && isDemoExternal(appId.value)) +) // ElectrumX shows a sync screen before its real UI (the Electrum server only // serves clients once its index is built). Poll /electrs-status while this is diff --git a/neode-ui/src/views/Chat.vue b/neode-ui/src/views/Chat.vue index e847d61d..0d110d0f 100644 --- a/neode-ui/src/views/Chat.vue +++ b/neode-ui/src/views/Chat.vue @@ -62,6 +62,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { ContextBroker } from '@/services/contextBroker' +import { IS_DEMO } from '@/composables/useDemoIntro' const { t } = useI18n() @@ -71,9 +72,12 @@ const aiuiConnected = ref(false) let broker: ContextBroker | null = null const aiuiUrl = computed(() => { + // Demo: ?mockArchy makes AIUI use its built-in mock node data (apps, system, + // network, wallet, bitcoin, files) and &seed pre-loads the example chats. + const demo = IS_DEMO ? '&mockArchy=1&seed=1' : '' const envUrl = import.meta.env.VITE_AIUI_URL - if (envUrl) return `${envUrl}?embedded=true&hideClose=true` - if (import.meta.env.PROD) return '/aiui/?embedded=true&hideClose=true' + if (envUrl) return `${envUrl}?embedded=true&hideClose=true${demo}` + if (import.meta.env.PROD || IS_DEMO) return `/aiui/?embedded=true&hideClose=true${demo}` return '' }) diff --git a/neode-ui/src/views/Login.vue b/neode-ui/src/views/Login.vue index 12ca0062..63aff776 100644 --- a/neode-ui/src/views/Login.vue +++ b/neode-ui/src/views/Login.vue @@ -156,6 +156,11 @@