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/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/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/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/mock-backend.js b/neode-ui/mock-backend.js index eeb76e16..26c91c69 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -17,14 +17,34 @@ 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' + +// 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 +67,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 +108,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, @@ -103,7 +140,7 @@ const walletState = { } function randomHex(bytes) { return Array.from({length: bytes}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join('') } -const bitcoinRelayMockState = { +const SEED_BTCRELAY = { settings: { enabled_for_peers: true, allow_peer_requests: true, @@ -144,77 +181,45 @@ 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 mode the effective dev mode is "onboarding" so the intro/onboarding can +// play for each new visitor (the per-day replay gate lives in the frontend). +function seedUserState() { + const mode = DEMO ? 'onboarding' : 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 } +} -// 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 +681,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': { @@ -907,9 +912,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 +956,7 @@ function bitcoinRelayStatusPayload() { synced: true, blocks: 902418, headers: 902418, - chain: 'main', + chain: DEMO_CHAIN, status_ok: true, status_stale: false, error: null, @@ -1142,6 +1150,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' @@ -3012,10 +3025,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 +3264,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 +3311,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`, @@ -3320,53 +3333,153 @@ 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] || '') + 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)) }) // Claude API Proxy (reads ANTHROPIC_API_KEY from environment) @@ -3718,13 +3831,137 @@ 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 +3988,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,14 +4018,14 @@ 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) }) }) diff --git a/neode-ui/src/composables/useDemoIntro.ts b/neode-ui/src/composables/useDemoIntro.ts new file mode 100644 index 00000000..a05b15c9 --- /dev/null +++ b/neode-ui/src/composables/useDemoIntro.ts @@ -0,0 +1,49 @@ +/** + * 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 */ + } +} diff --git a/neode-ui/src/views/Login.vue b/neode-ui/src/views/Login.vue index 12ca0062..bbc477e4 100644 --- a/neode-ui/src/views/Login.vue +++ b/neode-ui/src/views/Login.vue @@ -156,6 +156,11 @@