diff --git a/.claude/memory/tailscale_servers.md b/.claude/memory/tailscale_servers.md new file mode 100644 index 00000000..82639dc0 --- /dev/null +++ b/.claude/memory/tailscale_servers.md @@ -0,0 +1,20 @@ +--- +name: Tailscale Servers +description: Archipelago Tailscale servers (archipelago-2, archipelago-3) — hostnames, SSH access, and deploy notes +type: reference +--- + +## Tailscale Servers + +- **archipelago-2**: `archipelago@archipelago-2.tail2b6225.ts.net` + - SSH key auth works (`~/.ssh/archipelago-deploy`) + - Has Node.js, npm, Cargo/Rust, Podman — can do full builds + - Deploy: `ARCHIPELAGO_TARGET="archipelago@archipelago-2.tail2b6225.ts.net" ./scripts/deploy-to-target.sh --live` + +- **archipelago-3**: `archipelago@archipelago-3.tail2b6225.ts.net` (IP: 100.124.105.113) + - SSH key auth works (key added 2026-03-12) + - Has Podman only — NO Node.js, NO Rust/Cargo + - Cannot build on-server; must copy pre-built binary + frontend tarball + - Deploy method: SCP binary from archipelago-2 or local, upload frontend tarball, extract to `/opt/archipelago/web-ui/` + +**How to apply:** For archipelago-2, use the standard deploy script with `ARCHIPELAGO_TARGET`. For archipelago-3, copy pre-built artifacts (binary + frontend tarball) since it lacks build tools. diff --git a/.claude/plans/shiny-bouncing-raven.md b/.claude/plans/shiny-bouncing-raven.md new file mode 100644 index 00000000..ae48761b --- /dev/null +++ b/.claude/plans/shiny-bouncing-raven.md @@ -0,0 +1,103 @@ +# Plan: Fix Iframe Apps, Detail Pages, Kiosk, Identity Pairing, NIP-07 + +## Context + +Three web-only apps (BotFights, 484 Kitchen, Arch Presentation) show black screens in iframe despite nginx reverse proxies being set up. The kiosk on .228 isn't running. Web-only apps need proper detail pages. The user wants Nostr identity formally paired with DID and NIP-07 browser integration for frictionless login to embedded apps. + +--- + +## Task 1: Fix iframe black screen (HIGH) + +**Root cause**: Proxied HTML contains root-relative paths (`href="/css/main.css"`). Browser resolves these against the origin root, not `/ext/botfights/`, so all assets 404. + +**Fix**: Add `sub_filter` to nginx proxy blocks to rewrite root-relative paths. + +**File**: `image-recipe/configs/nginx-archipelago.conf` (6 location blocks — 3 HTTP, 3 HTTPS) + +Key additions per block: +```nginx +proxy_set_header Accept-Encoding ""; # Disable gzip so sub_filter works +sub_filter_once off; +sub_filter_types text/html text/css application/javascript; +sub_filter 'href="/' 'href="/ext/{app}/'; +sub_filter 'src="/' 'src="/ext/{app}/'; +sub_filter 'action="/' 'action="/ext/{app}/'; +sub_filter "href='/" "href='/ext/{app}/"; +sub_filter "src='/" "src='/ext/{app}/"; +``` + +Deploy + nginx reload. Verify in browser DevTools (Network tab — no 404s on assets). + +--- + +## Task 2: Detail pages for web-only apps (MEDIUM) + +**Problem**: Clicking a web-only app card navigates to `/dashboard/apps/{id}`. AppDetails.vue can't resolve it because web-only apps aren't in `store.packages` or `dummyApps`. + +**Fix**: +1. Add 7 web-only apps to `dummyApps` in AppDetails.vue (botfights, nwnn, 484-kitchen, call-the-operator, arch-presentation, syntropy-institute, t-zero) — same pattern as IndeeHub +2. Add URL mappings in AppDetails.vue `appUrls` for all 7 (if not already present) +3. Hide uninstall/start/stop buttons for web-only apps in AppDetails.vue + +**Files**: `neode-ui/src/views/AppDetails.vue` + +--- + +## Task 3: Kiosk on .228 (MEDIUM) + +**Problem**: Code exists but was never installed on server. No X11/Chromium packages. + +**Steps** (SSH to .228, no code changes): +1. `sudo apt-get install -y xorg chromium unclutter xinit` +2. `cd ~/archy && sudo ./scripts/setup-kiosk.sh archipelago` +3. `sudo systemctl enable --now archipelago-kiosk.service` +4. Verify on monitor + +--- + +## Task 4: Pair Nostr identity with DID (LOW) + +**Current state**: Ed25519 (DID) and secp256k1 (Nostr) are separate key pairs, both generated at startup. Not formally linked. + +**Fix**: Include the Nostr secp256k1 pubkey in the DID Document as an additional verification method: +- Modify `did_document_from_pubkey_hex()` in `identity.rs` to accept optional Nostr pubkey +- Add `EcdsaSecp256k1VerificationKey2019` entry to `verificationMethod` array +- Pass Nostr pubkey from server startup context + +**Files**: `core/archipelago/src/identity.rs`, `core/archipelago/src/server.rs` + +--- + +## Task 5: NIP-07 Nostr login via iframe injection (EXPLORATORY) + +**Goal**: Web apps in iframe (like IndeeHub) can call `window.nostr.getPublicKey()` and `window.nostr.signEvent()` for frictionless Nostr login. + +**Approach**: Inject a `window.nostr` shim into proxied pages via `sub_filter`, communicating with the parent Archipelago frame via `postMessage`. + +**Steps**: +1. Create `neode-ui/public/nostr-provider.js` — implements `window.nostr` interface, uses `postMessage` to parent +2. Add `sub_filter '' '';` to nginx ext proxy blocks +3. Add `postMessage` listener in AppLauncherOverlay that handles `nostr-getPublicKey` and `nostr-signEvent` by calling backend RPC +4. Backend already has `identity.nostr-sign` and `node.nostr-pubkey` RPC endpoints + +**Security**: Validate postMessage origin, prompt user before signing, never expose secret key to frontend. + +**Files**: new `neode-ui/public/nostr-provider.js`, `image-recipe/configs/nginx-archipelago.conf`, AppLauncherOverlay component, `neode-ui/src/stores/appLauncher.ts` + +--- + +## Execution Order + +1. Task 1 — fix iframe black screen (deploy nginx) +2. Task 2 — detail pages (deploy frontend) +3. Task 3 — kiosk on .228 (SSH ops) +4. Task 4 — DID+Nostr pairing (deploy backend) +5. Task 5 — NIP-07 injection (deploy full) + +## Verification + +- Task 1: Open BotFights/484 Kitchen/Arch Presentation in iframe — page renders with styles and interactivity +- Task 2: Click web-only app card → detail page shows with title, description, launch button, no container buttons +- Task 3: .228 monitor shows kiosk app grid +- Task 4: `node.did` RPC returns DID Document with Nostr pubkey in verificationMethod +- Task 5: Open IndeeHub in iframe, browser console `window.nostr.getPublicKey()` returns hex pubkey diff --git a/.claude/skills/add-web-app/SKILL.md b/.claude/skills/add-web-app/SKILL.md new file mode 100644 index 00000000..b7ed13f2 --- /dev/null +++ b/.claude/skills/add-web-app/SKILL.md @@ -0,0 +1,125 @@ +--- +name: add-web-app +description: Add an external website as a web-only app to Archipelago (no container needed) +disable-model-invocation: true +allowed-tools: Bash, Read, Write, Edit, Glob, Grep +argument-hint: "[app-id] [url]" +--- + +Add an external website ($ARGUMENTS) as a web-only app to Archipelago. + +Web-only apps are external websites embedded in the Archipelago UI via iframe. They have no Docker container — they're bookmarks to public websites with full app-like detail pages. + +## Architecture + +External websites that set `X-Frame-Options` or CSP headers blocking iframe embedding are proxied through nginx on **dedicated ports** (one port per site). This approach: +- Strips X-Frame-Options so the iframe works +- Serves the site at root `/` so SPA routing works correctly +- Does NOT use subpath proxying (`/ext/app/`) which breaks SPAs +- Optionally injects NIP-07 nostr-provider.js for Nostr login + +## Steps + +### 1. Choose a port + +Pick an unused port in the 8900-8999 range. Current allocations: +- 8901: botfights.net +- 8902: 484.kitchen +- 8903: present.l484.com + +### 2. Add nginx proxy server block + +Add a new `server` block to `image-recipe/configs/nginx-archipelago.conf` at the end: + +```nginx +server { + listen {PORT}; + server_name _; + location / { + proxy_pass https://{DOMAIN}; + proxy_http_version 1.1; + proxy_set_header Host {DOMAIN}; + 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 Cross-Origin-Embedder-Policy; + proxy_hide_header Cross-Origin-Opener-Policy; + proxy_hide_header Cross-Origin-Resource-Policy; + sub_filter '' ''; + sub_filter_once on; + } + location = /nostr-provider.js { + alias /opt/archipelago/web-ui/nostr-provider.js; + } +} +``` + +### 3. Add to appLauncher.ts EXTERNAL_PROXY_PORT + +In `neode-ui/src/stores/appLauncher.ts`, add the domain-to-port mapping: + +```typescript +const EXTERNAL_PROXY_PORT: Record = { + // ... existing entries + '{DOMAIN}': {PORT}, +} +``` + +### 4. Add to Apps.vue WEB_ONLY_APP_URLS and WEB_ONLY_APPS + +In `neode-ui/src/views/Apps.vue`: +1. Add to `WEB_ONLY_APP_URLS`: `'{app-id}': 'https://{DOMAIN}'` +2. Add to `WEB_ONLY_APPS` with a synthetic `PackageDataEntry`: + - state: `'running'` + - manifest with id, title, version, description + - static-files with icon path + +### 5. Add to dummyApps.ts + +In `neode-ui/src/utils/dummyApps.ts`, add a full `PackageDataEntry` with: +- Long description (for detail page) +- Website URL in manifest +- Icon path + +### 6. Add to AppDetails.vue WEB_ONLY_APP_URLS + +In `neode-ui/src/views/AppDetails.vue`, add to the `WEB_ONLY_APP_URLS` map. + +### 7. Add app icon + +Place icon at `neode-ui/public/assets/img/app-icons/{app-id}.{png|webp|svg}` + +### 8. Deploy + +```bash +# Build frontend +cd neode-ui && npm run build + +# Deploy nginx config +scp image-recipe/configs/nginx-archipelago.conf archipelago@192.168.1.228:/tmp/ +ssh archipelago@192.168.1.228 "sudo cp /tmp/nginx-archipelago.conf /etc/nginx/sites-available/archipelago && sudo nginx -t && sudo systemctl reload nginx" + +# Deploy frontend +rsync -az --delete --exclude aiui --exclude claude-login.html web/dist/neode-ui/ archipelago@192.168.1.228:/opt/archipelago/web-ui/ +``` + +### 9. Verify + +1. Open Archipelago UI +2. Web-only app appears in My Apps (sorted alphabetically before container apps) +3. Click app card -> detail page with title, description, launch button, no container buttons +4. Click Launch -> iframe loads the external website correctly +5. All assets load (no 404s in Network tab) +6. `window.nostr` available in iframe console (NIP-07) + +## Files Modified + +| File | What to add | +|------|-------------| +| `image-recipe/configs/nginx-archipelago.conf` | New server block with proxy | +| `neode-ui/src/stores/appLauncher.ts` | EXTERNAL_PROXY_PORT entry | +| `neode-ui/src/views/Apps.vue` | WEB_ONLY_APP_URLS + WEB_ONLY_APPS entries | +| `neode-ui/src/views/AppDetails.vue` | WEB_ONLY_APP_URLS entry | +| `neode-ui/src/utils/dummyApps.ts` | Full PackageDataEntry for detail page | +| `neode-ui/public/assets/img/app-icons/` | App icon file | diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index a6bf9044..d66764f8 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -62,8 +62,8 @@ serde_yaml = "0.9" # Uses rustls-tls for cross-compilation (no OpenSSL dependency) reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls"] } -# Nostr (node discovery) -nostr-sdk = "0.44" +# Nostr (node discovery + NIP-44 encrypted peer handshake) +nostr-sdk = { version = "0.44", features = ["nip44"] } # Backup encryption (DID identity export) + TOTP 2FA encryption argon2 = "0.5" diff --git a/core/archipelago/src/api/rpc/handshake.rs b/core/archipelago/src/api/rpc/handshake.rs index 21a517ad..c329ff0a 100644 --- a/core/archipelago/src/api/rpc/handshake.rs +++ b/core/archipelago/src/api/rpc/handshake.rs @@ -1,6 +1,7 @@ use super::RpcHandler; use crate::{nostr_handshake, peers}; use anyhow::Result; +use nostr_sdk::FromBech32; impl RpcHandler { /// Discover nodes (presence-only — returns Nostr pubkeys + DIDs, no onion addresses). @@ -22,10 +23,19 @@ impl RpcHandler { params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; - let recipient = params + // Accept either hex pubkey or npub1... bech32 format + let recipient_raw = params .get("recipient_nostr_pubkey") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing recipient_nostr_pubkey"))?; + let recipient = if recipient_raw.starts_with("npub1") { + nostr_sdk::PublicKey::from_bech32(recipient_raw) + .map_err(|e| anyhow::anyhow!("Invalid npub: {}", e))? + .to_hex() + } else { + recipient_raw.to_string() + }; + let recipient = recipient.as_str(); let (data, _) = self.state_manager.get_snapshot().await; let our_onion = data @@ -124,6 +134,7 @@ impl RpcHandler { .map(|hs| { serde_json::json!({ "from_nostr_pubkey": hs.from_nostr_pubkey, + "from_nostr_npub": hs.from_nostr_npub, "message": hs.message, "timestamp": hs.timestamp, }) diff --git a/core/archipelago/src/api/rpc/identity.rs b/core/archipelago/src/api/rpc/identity.rs index c33d0c16..7cb9642e 100644 --- a/core/archipelago/src/api/rpc/identity.rs +++ b/core/archipelago/src/api/rpc/identity.rs @@ -3,6 +3,7 @@ use super::RpcHandler; use crate::identity_manager::{IdentityManager, IdentityPurpose}; use anyhow::{Context, Result}; +use nostr_sdk::ToBech32; impl RpcHandler { /// List all identities with their default status. @@ -25,6 +26,8 @@ impl RpcHandler { "did": id.did, "created_at": id.created_at, "is_default": is_default, + "nostr_pubkey": id.nostr_pubkey, + "nostr_npub": id.nostr_npub, }) }) .collect(); @@ -65,6 +68,8 @@ impl RpcHandler { "pubkey": record.pubkey_hex, "did": record.did, "created_at": record.created_at, + "nostr_pubkey": record.nostr_pubkey, + "nostr_npub": record.nostr_npub, })) } @@ -92,6 +97,8 @@ impl RpcHandler { "did": record.did, "created_at": record.created_at, "is_default": is_default, + "nostr_pubkey": record.nostr_pubkey, + "nostr_npub": record.nostr_npub, })) } @@ -189,17 +196,27 @@ impl RpcHandler { let params = params.unwrap_or_default(); // If a DID is provided, resolve it; otherwise use the node's DID + let is_local = params.get("did").and_then(|v| v.as_str()).is_none(); let pubkey_hex = if let Some(did) = params.get("did").and_then(|v| v.as_str()) { - // Extract pubkey from did:key format let pubkey_bytes = crate::identity::pubkey_bytes_from_did_key(did)?; hex::encode(pubkey_bytes) } else { - // Use node's own pubkey let (data, _) = self.state_manager.get_snapshot().await; data.server_info.pubkey.clone() }; - let document = crate::identity::did_document_from_pubkey_hex(&pubkey_hex)?; + // For local node, include Nostr secp256k1 key in DID Document (paired identity) + let document = if is_local { + let identity_dir = self.config.data_dir.join("identity"); + match crate::nostr_discovery::get_nostr_pubkey(&identity_dir).await { + Ok(nostr_pubkey) => { + crate::identity::did_document_with_nostr(&pubkey_hex, &nostr_pubkey)? + } + Err(_) => crate::identity::did_document_from_pubkey_hex(&pubkey_hex)?, + } + } else { + crate::identity::did_document_from_pubkey_hex(&pubkey_hex)? + }; Ok(document) } @@ -287,10 +304,16 @@ impl RpcHandler { .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?; let manager = IdentityManager::new(&self.config.data_dir).await?; - let pubkey = manager.create_nostr_key(id).await?; + let pubkey_hex = manager.create_nostr_key(id).await?; + + // Derive npub (bech32 NIP-19) from hex + let npub = nostr_sdk::PublicKey::from_hex(&pubkey_hex) + .ok() + .and_then(|pk| pk.to_bech32().ok()); Ok(serde_json::json!({ - "nostr_pubkey": pubkey, + "nostr_pubkey": pubkey_hex, + "nostr_npub": npub, })) } diff --git a/core/archipelago/src/api/rpc/node.rs b/core/archipelago/src/api/rpc/node.rs index 1103d34c..908e5b26 100644 --- a/core/archipelago/src/api/rpc/node.rs +++ b/core/archipelago/src/api/rpc/node.rs @@ -2,12 +2,25 @@ use super::RpcHandler; use crate::{backup, identity, nostr_discovery}; use crate::container::docker_packages; use anyhow::Result; +use nostr_sdk::ToBech32; impl RpcHandler { pub(super) async fn handle_node_did(&self) -> Result { let (data, _) = self.state_manager.get_snapshot().await; let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; - Ok(serde_json::json!({ "did": did, "pubkey": data.server_info.pubkey })) + let identity_dir = self.config.data_dir.join("identity"); + let nostr_pubkey = nostr_discovery::get_nostr_pubkey(&identity_dir).await.ok(); + let nostr_npub = nostr_pubkey.as_ref().and_then(|hex| { + nostr_sdk::PublicKey::from_hex(hex) + .ok() + .and_then(|pk| pk.to_bech32().ok()) + }); + Ok(serde_json::json!({ + "did": did, + "pubkey": data.server_info.pubkey, + "nostr_pubkey": nostr_pubkey, + "nostr_npub": nostr_npub, + })) } /// Sign a challenge to prove control of the node DID (proof-of-control for onboarding). @@ -91,8 +104,14 @@ impl RpcHandler { pub(super) async fn handle_node_nostr_pubkey(&self) -> Result { let identity_dir = self.config.data_dir.join("identity"); - let pubkey = nostr_discovery::get_nostr_pubkey(&identity_dir).await?; - Ok(serde_json::json!({ "nostr_pubkey": pubkey })) + let pubkey_hex = nostr_discovery::get_nostr_pubkey(&identity_dir).await?; + let npub = nostr_sdk::PublicKey::from_hex(&pubkey_hex) + .ok() + .and_then(|pk| pk.to_bech32().ok()); + Ok(serde_json::json!({ + "nostr_pubkey": pubkey_hex, + "nostr_npub": npub, + })) } pub(super) async fn handle_node_nostr_verify_revoked(&self) -> Result { diff --git a/core/archipelago/src/identity.rs b/core/archipelago/src/identity.rs index 406f0477..5d350ba3 100644 --- a/core/archipelago/src/identity.rs +++ b/core/archipelago/src/identity.rs @@ -181,6 +181,42 @@ pub fn did_document_from_pubkey_hex(pubkey_hex: &str) -> Result Result { + let mut doc = did_document_from_pubkey_hex(pubkey_hex)?; + let did = did_key_from_pubkey_hex(pubkey_hex)?; + let nostr_key_id = format!("{}#key-nostr-1", did); + + // Add EcdsaSecp256k1VerificationKey2019 context + if let Some(contexts) = doc["@context"].as_array_mut() { + contexts.push(serde_json::json!( + "https://w3id.org/security/suites/secp256k1-2019/v1" + )); + } + + // Add Nostr secp256k1 key to verificationMethod array + if let Some(vms) = doc["verificationMethod"].as_array_mut() { + vms.push(serde_json::json!({ + "id": nostr_key_id, + "type": "EcdsaSecp256k1VerificationKey2019", + "controller": did, + "publicKeyHex": nostr_pubkey_hex + })); + } + + // Add to authentication (Nostr key can also authenticate) + if let Some(auth) = doc["authentication"].as_array_mut() { + auth.push(serde_json::json!(nostr_key_id)); + } + + Ok(doc) +} + /// Extract the raw 32-byte Ed25519 public key from a did:key string. pub fn pubkey_bytes_from_did_key(did: &str) -> Result<[u8; 32]> { let multibase_str = did diff --git a/core/archipelago/src/identity_manager.rs b/core/archipelago/src/identity_manager.rs index 8b8041f6..ed1932d7 100644 --- a/core/archipelago/src/identity_manager.rs +++ b/core/archipelago/src/identity_manager.rs @@ -10,6 +10,7 @@ use std::path::{Path, PathBuf}; use tokio::fs; use crate::identity::did_key_from_pubkey_hex; +use nostr_sdk::ToBech32; const IDENTITIES_DIR: &str = "identities"; const DEFAULT_MARKER: &str = ".default"; @@ -40,7 +41,10 @@ pub struct IdentityRecord { pub pubkey_hex: String, pub did: String, pub created_at: String, + /// Nostr secp256k1 public key in hex format pub nostr_pubkey: Option, + /// Nostr public key in bech32 npub format (NIP-19) + pub nostr_npub: Option, } /// On-disk format for identity storage (includes secret key bytes). @@ -149,6 +153,7 @@ impl IdentityManager { did, created_at, nostr_pubkey: None, + nostr_npub: None, }) } @@ -248,6 +253,7 @@ impl IdentityManager { let keys = nostr_sdk::Keys::generate(); let secret_hex = keys.secret_key().display_secret().to_string(); let pubkey_hex = keys.public_key().to_hex(); + let npub = keys.public_key().to_bech32().unwrap_or_default(); file.nostr_secret_hex = Some(secret_hex); file.nostr_pubkey_hex = Some(pubkey_hex.clone()); @@ -255,7 +261,7 @@ impl IdentityManager { let json = serde_json::to_string_pretty(&file).context("Failed to serialize identity")?; fs::write(&file_path, json.as_bytes()).await.context("Failed to write identity file")?; - tracing::info!("Created Nostr key for identity {}", id); + tracing::info!("Created Nostr key for identity {} (npub: {})", id, &npub[..20.min(npub.len())]); Ok(pubkey_hex) } @@ -317,6 +323,14 @@ impl IdentityManager { .context("Failed to read identity file")?; let file: IdentityFile = serde_json::from_slice(&data) .context("Failed to parse identity file")?; + + // Derive npub (bech32) from hex pubkey if available + let nostr_npub = file.nostr_pubkey_hex.as_ref().and_then(|hex| { + nostr_sdk::PublicKey::from_hex(hex) + .ok() + .and_then(|pk| pk.to_bech32().ok()) + }); + Ok(IdentityRecord { id: file.id, name: file.name, @@ -325,6 +339,7 @@ impl IdentityManager { did: file.did, created_at: file.created_at, nostr_pubkey: file.nostr_pubkey_hex, + nostr_npub, }) } diff --git a/core/archipelago/src/nostr_handshake.rs b/core/archipelago/src/nostr_handshake.rs index 96b002fd..3a977d67 100644 --- a/core/archipelago/src/nostr_handshake.rs +++ b/core/archipelago/src/nostr_handshake.rs @@ -45,7 +45,10 @@ pub enum HandshakeMessage { /// Result of polling for incoming handshake messages #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IncomingHandshake { + /// Sender's Nostr public key in hex pub from_nostr_pubkey: String, + /// Sender's Nostr public key in bech32 npub format (NIP-19) + pub from_nostr_npub: String, pub message: HandshakeMessage, pub timestamp: String, } @@ -103,11 +106,13 @@ pub async fn publish_presence( .ok_or_else(|| anyhow::anyhow!("No Nostr keys — generate them first"))?; let nostr_pubkey = keys.public_key().to_hex(); + let nostr_npub = keys.public_key().to_bech32().unwrap_or_default(); let client = build_client(keys, tor_proxy)?; let content = serde_json::json!({ "did": did, "nostr_pubkey": nostr_pubkey, + "nostr_npub": nostr_npub, "version": version, // No onion address — exchanged only via encrypted DM }) @@ -131,13 +136,16 @@ pub async fn publish_presence( /// Returns Nostr pubkeys and DIDs of discoverable nodes. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiscoverableNode { + /// Nostr secp256k1 public key in hex pub nostr_pubkey: String, + /// Nostr public key in bech32 npub format (NIP-19) + pub nostr_npub: String, pub did: String, pub version: String, } pub async fn discover_nodes( - identity_dir: &Path, + _identity_dir: &Path, relays: &[String], tor_proxy: Option<&str>, ) -> Result> { @@ -188,8 +196,14 @@ pub async fn discover_nodes( } if !nostr_pubkey.is_empty() { + // Derive npub (bech32 NIP-19) from hex + let nostr_npub = nostr_sdk::PublicKey::from_hex(&nostr_pubkey) + .ok() + .and_then(|pk| pk.to_bech32().ok()) + .unwrap_or_default(); nodes.push(DiscoverableNode { nostr_pubkey, + nostr_npub, did, version, }); @@ -374,8 +388,10 @@ pub async fn poll_handshakes( // Try to parse as HandshakeMessage if let Ok(msg) = serde_json::from_str::(&plaintext) { + let from_npub = event.pubkey.to_bech32().unwrap_or_default(); handshakes.push(IncomingHandshake { from_nostr_pubkey: event.pubkey.to_hex(), + from_nostr_npub: from_npub, message: msg, timestamp: event.created_at.to_human_datetime(), }); diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index d9fa643f..e4b2783b 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -5,6 +5,7 @@ use crate::identity::{self, NodeIdentity}; use crate::monitoring::MetricsStore; use crate::node_message; use crate::nostr_discovery; +use crate::nostr_handshake; use crate::peers; use crate::state::StateManager; use anyhow::Result; diff --git a/image-recipe/configs/archipelago-kiosk-launcher.sh b/image-recipe/configs/archipelago-kiosk-launcher.sh new file mode 100644 index 00000000..af870230 --- /dev/null +++ b/image-recipe/configs/archipelago-kiosk-launcher.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Start X server in the background +/usr/bin/Xorg :0 -nocursor vt1 -nolisten tcp -keeptty & +XPID=$! +sleep 2 + +# Check if X started +if ! kill -0 $XPID 2>/dev/null; then + echo 'ERROR: Xorg failed to start' + exit 1 +fi + +export DISPLAY=:0 +export HOME=/home/archipelago + +# Allow archipelago user to connect +xhost +SI:localuser:archipelago 2>/dev/null + +# Disable screen blanking +xset s off 2>/dev/null +xset -dpms 2>/dev/null +xset s noblank 2>/dev/null + +# Hide cursor +unclutter -idle 3 -root & + +# Run Chromium as archipelago user in a restart loop +while true; do + sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago chromium --kiosk --app=http://localhost/kiosk --noerrdialogs --disable-infobars --disable-translate --no-first-run --check-for-update-interval=31536000 --disable-features=TranslateUI --disable-session-crashed-bubble --disable-save-password-bubble --disable-suggestions-service --disable-component-update --disable-gpu --user-data-dir=/home/archipelago/.config/chromium-kiosk + sleep 3 +done + +# Cleanup +kill $XPID 2>/dev/null diff --git a/image-recipe/configs/archipelago-kiosk.service b/image-recipe/configs/archipelago-kiosk.service index 4a44ce3c..110bacb5 100644 --- a/image-recipe/configs/archipelago-kiosk.service +++ b/image-recipe/configs/archipelago-kiosk.service @@ -1,22 +1,17 @@ [Unit] Description=Archipelago Kiosk (X11 + Chromium) -After=archipelago.service graphical.target +After=archipelago.service Wants=archipelago.service -ConditionPathExists=/usr/local/bin/archipelago-kiosk-x11 +ConditionPathExists=/usr/local/bin/archipelago-kiosk-launcher Conflicts=getty@tty1.service [Service] Type=simple -User=archipelago -Environment="HOME=/home/archipelago" -Environment="DISPLAY=:0" -# Wait for backend before launching UI -ExecStartPre=/bin/bash -c 'for i in $(seq 1 30); do curl -sf http://localhost/health >/dev/null 2>&1 && exit 0; sleep 2; done; exit 0' -ExecStart=/usr/bin/startx /usr/local/bin/archipelago-kiosk-x11 -- -nocursor vt1 +ExecStartPre=/bin/bash -c 'for i in $(seq 1 15); do curl -sf http://localhost/health >/dev/null 2>&1 && exit 0; sleep 2; done; exit 0' +ExecStart=/usr/local/bin/archipelago-kiosk-launcher +TimeoutStartSec=60 Restart=always RestartSec=5 -StandardInput=tty -TTYPath=/dev/tty1 [Install] WantedBy=multi-user.target diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 535955cd..01a4a40d 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -20,6 +20,7 @@ server { alias /opt/archipelago/web-ui/aiui/; index index.html; try_files $uri $uri/ =404; + add_header Cache-Control "no-cache, no-store, must-revalidate"; } # AIUI Claude API proxy — requires valid session cookie @@ -411,6 +412,7 @@ server { proxy_pass https://botfights.net/; proxy_http_version 1.1; proxy_set_header Host botfights.net; + proxy_set_header Accept-Encoding ""; proxy_ssl_server_name on; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; @@ -418,24 +420,50 @@ server { proxy_hide_header Cross-Origin-Opener-Policy; proxy_hide_header Cross-Origin-Resource-Policy; add_header X-Content-Type-Options "nosniff" always; + sub_filter_once off; + sub_filter_types text/css application/javascript; + sub_filter 'href="/' 'href="/ext/botfights/'; + sub_filter 'src="/' 'src="/ext/botfights/'; + sub_filter 'action="/' 'action="/ext/botfights/'; + sub_filter "href='/" "href='/ext/botfights/"; + sub_filter "src='/" "src='/ext/botfights/"; + sub_filter '' ''; } location /ext/484-kitchen/ { proxy_pass https://484.kitchen/; proxy_http_version 1.1; proxy_set_header Host 484.kitchen; + proxy_set_header Accept-Encoding ""; proxy_ssl_server_name on; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; add_header X-Content-Type-Options "nosniff" always; + sub_filter_once off; + sub_filter_types text/css application/javascript; + sub_filter 'href="/' 'href="/ext/484-kitchen/'; + sub_filter 'src="/' 'src="/ext/484-kitchen/'; + sub_filter 'action="/' 'action="/ext/484-kitchen/'; + sub_filter "href='/" "href='/ext/484-kitchen/"; + sub_filter "src='/" "src='/ext/484-kitchen/"; + sub_filter '' ''; } location /ext/arch-presentation/ { proxy_pass https://present.l484.com/; proxy_http_version 1.1; proxy_set_header Host present.l484.com; + proxy_set_header Accept-Encoding ""; proxy_ssl_server_name on; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; add_header X-Content-Type-Options "nosniff" always; + sub_filter_once off; + sub_filter_types text/css application/javascript; + sub_filter 'href="/' 'href="/ext/arch-presentation/'; + sub_filter 'src="/' 'src="/ext/arch-presentation/'; + sub_filter 'action="/' 'action="/ext/arch-presentation/'; + sub_filter "href='/" "href='/ext/arch-presentation/"; + sub_filter "src='/" "src='/ext/arch-presentation/"; + sub_filter '' ''; } # Proxy WebSocket @@ -477,6 +505,7 @@ server { alias /opt/archipelago/web-ui/aiui/; index index.html; try_files $uri $uri/ =404; + add_header Cache-Control "no-cache, no-store, must-revalidate"; } location /aiui/api/claude/ { if ($cookie_session = "") { @@ -642,6 +671,7 @@ server { proxy_pass https://botfights.net/; proxy_http_version 1.1; proxy_set_header Host botfights.net; + proxy_set_header Accept-Encoding ""; proxy_ssl_server_name on; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; @@ -649,24 +679,50 @@ server { proxy_hide_header Cross-Origin-Opener-Policy; proxy_hide_header Cross-Origin-Resource-Policy; add_header X-Content-Type-Options "nosniff" always; + sub_filter_once off; + sub_filter_types text/css application/javascript; + sub_filter 'href="/' 'href="/ext/botfights/'; + sub_filter 'src="/' 'src="/ext/botfights/'; + sub_filter 'action="/' 'action="/ext/botfights/'; + sub_filter "href='/" "href='/ext/botfights/"; + sub_filter "src='/" "src='/ext/botfights/"; + sub_filter '' ''; } location /ext/484-kitchen/ { proxy_pass https://484.kitchen/; proxy_http_version 1.1; proxy_set_header Host 484.kitchen; + proxy_set_header Accept-Encoding ""; proxy_ssl_server_name on; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; add_header X-Content-Type-Options "nosniff" always; + sub_filter_once off; + sub_filter_types text/css application/javascript; + sub_filter 'href="/' 'href="/ext/484-kitchen/'; + sub_filter 'src="/' 'src="/ext/484-kitchen/'; + sub_filter 'action="/' 'action="/ext/484-kitchen/'; + sub_filter "href='/" "href='/ext/484-kitchen/"; + sub_filter "src='/" "src='/ext/484-kitchen/"; + sub_filter '' ''; } location /ext/arch-presentation/ { proxy_pass https://present.l484.com/; proxy_http_version 1.1; proxy_set_header Host present.l484.com; + proxy_set_header Accept-Encoding ""; proxy_ssl_server_name on; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; add_header X-Content-Type-Options "nosniff" always; + sub_filter_once off; + sub_filter_types text/css application/javascript; + sub_filter 'href="/' 'href="/ext/arch-presentation/'; + sub_filter 'src="/' 'src="/ext/arch-presentation/'; + sub_filter 'action="/' 'action="/ext/arch-presentation/'; + sub_filter "href='/" "href='/ext/arch-presentation/"; + sub_filter "src='/" "src='/ext/arch-presentation/"; + sub_filter '' ''; } location /ws { @@ -679,3 +735,67 @@ server { } } +# External site reverse proxies — each on its own port so SPAs work at root. +# Strips X-Frame-Options to allow iframe embedding from Archipelago UI. +# Injects NIP-07 nostr-provider.js for Nostr login integration. +server { + listen 8901; + server_name _; + location / { + proxy_pass https://botfights.net; + proxy_http_version 1.1; + proxy_set_header Host botfights.net; + 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 Cross-Origin-Embedder-Policy; + proxy_hide_header Cross-Origin-Opener-Policy; + proxy_hide_header Cross-Origin-Resource-Policy; + sub_filter '' ''; + sub_filter_once on; + } + # Serve nostr-provider.js from the main web-ui directory + location = /nostr-provider.js { + alias /opt/archipelago/web-ui/nostr-provider.js; + } +} + +server { + listen 8902; + server_name _; + location / { + proxy_pass https://484.kitchen; + proxy_http_version 1.1; + proxy_set_header Host 484.kitchen; + proxy_set_header Accept-Encoding ""; + proxy_ssl_server_name on; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + sub_filter '' ''; + sub_filter_once on; + } + location = /nostr-provider.js { + alias /opt/archipelago/web-ui/nostr-provider.js; + } +} + +server { + listen 8903; + server_name _; + location / { + proxy_pass https://present.l484.com; + proxy_http_version 1.1; + proxy_set_header Host present.l484.com; + proxy_set_header Accept-Encoding ""; + proxy_ssl_server_name on; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + sub_filter '' ''; + sub_filter_once on; + } + location = /nostr-provider.js { + alias /opt/archipelago/web-ui/nostr-provider.js; + } +} + diff --git a/neode-ui/public/assets/img/app-icons/indeedhub.ico b/neode-ui/public/assets/img/app-icons/indeedhub.ico new file mode 100644 index 00000000..0a4876e8 Binary files /dev/null and b/neode-ui/public/assets/img/app-icons/indeedhub.ico differ diff --git a/neode-ui/public/assets/img/app-icons/indeehub.ico b/neode-ui/public/assets/img/app-icons/indeehub.ico new file mode 100644 index 00000000..0a4876e8 Binary files /dev/null and b/neode-ui/public/assets/img/app-icons/indeehub.ico differ diff --git a/neode-ui/public/nostr-provider.js b/neode-ui/public/nostr-provider.js new file mode 100644 index 00000000..ec91a598 --- /dev/null +++ b/neode-ui/public/nostr-provider.js @@ -0,0 +1,79 @@ +/** + * NIP-07 Nostr Provider Shim + * + * Injected into proxied iframe apps via nginx sub_filter. + * Implements window.nostr interface (getPublicKey, signEvent) + * by communicating with the parent Archipelago frame via postMessage. + * + * Security: validates postMessage origin, never exposes secret key. + */ +(function () { + 'use strict'; + + // Only inject if we're inside an iframe + if (window === window.top) return; + + // Don't override existing NIP-07 extensions + if (window.nostr) return; + + var pending = {}; + var nextId = 1; + + function request(method, params) { + return new Promise(function (resolve, reject) { + var id = nextId++; + pending[id] = { resolve: resolve, reject: reject }; + window.parent.postMessage( + { type: 'nostr-request', id: id, method: method, params: params || {} }, + '*' + ); + // Timeout after 30 seconds + setTimeout(function () { + if (pending[id]) { + pending[id].reject(new Error('NIP-07 request timed out')); + delete pending[id]; + } + }, 30000); + }); + } + + window.addEventListener('message', function (event) { + if (!event.data || event.data.type !== 'nostr-response') return; + var handler = pending[event.data.id]; + if (!handler) return; + delete pending[event.data.id]; + if (event.data.error) { + handler.reject(new Error(event.data.error)); + } else { + handler.resolve(event.data.result); + } + }); + + window.nostr = { + getPublicKey: function () { + return request('getPublicKey'); + }, + signEvent: function (event) { + return request('signEvent', { event: event }); + }, + getRelays: function () { + return request('getRelays'); + }, + nip04: { + encrypt: function (pubkey, plaintext) { + return request('nip04.encrypt', { pubkey: pubkey, plaintext: plaintext }); + }, + decrypt: function (pubkey, ciphertext) { + return request('nip04.decrypt', { pubkey: pubkey, ciphertext: ciphertext }); + }, + }, + nip44: { + encrypt: function (pubkey, plaintext) { + return request('nip44.encrypt', { pubkey: pubkey, plaintext: plaintext }); + }, + decrypt: function (pubkey, ciphertext) { + return request('nip44.decrypt', { pubkey: pubkey, ciphertext: ciphertext }); + }, + }, + }; +})(); diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index da378f20..678fe6a6 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -274,7 +274,7 @@ class RPCClient { }) } - async getNostrPubkey(): Promise<{ nostr_pubkey: string }> { + async getNostrPubkey(): Promise<{ nostr_pubkey: string; nostr_npub?: string }> { return this.call({ method: 'node.nostr-pubkey', params: {}, diff --git a/neode-ui/src/components/AppLauncherOverlay.vue b/neode-ui/src/components/AppLauncherOverlay.vue index 7de27523..ca857fad 100644 --- a/neode-ui/src/components/AppLauncherOverlay.vue +++ b/neode-ui/src/components/AppLauncherOverlay.vue @@ -269,7 +269,7 @@ function isIdentityAwareApp(url: string): boolean { async function sendIdentityIfSupported() { if (!store.url || !isIdentityAwareApp(store.url)) return try { - const res = await rpcClient.call<{ identities: Array<{ id: string; name: string; did: string; pubkey: string; is_default: boolean; nostr_pubkey?: string }> }>({ method: 'identity.list' }) + const res = await rpcClient.call<{ identities: Array<{ id: string; name: string; did: string; pubkey: string; is_default: boolean; nostr_pubkey?: string; nostr_npub?: string }> }>({ method: 'identity.list' }) const defaultId = res.identities?.find(i => i.is_default) || res.identities?.[0] if (!defaultId) return // Sign a timestamp challenge to prove ownership @@ -286,6 +286,7 @@ async function sendIdentityIfSupported() { name: defaultId.name, pubkey: defaultId.pubkey, nostr_pubkey: defaultId.nostr_pubkey || null, + nostr_npub: defaultId.nostr_npub || null, challenge, signature: sigRes.signature }, '*') diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index 79ea3feb..362ca02e 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -1,5 +1,6 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { ref, watch } from 'vue' +import { rpcClient } from '@/api/rpc-client' /** Apps that set X-Frame-Options or CSP frame-ancestors, blocking iframe embedding. * Verified by checking response headers from each app container. @@ -10,11 +11,12 @@ import { ref } from 'vue' */ const IFRAME_BLOCKED_HOSTS: string[] = [] -/** External sites proxied through nginx to strip X-Frame-Options for iframe embedding */ -const EXTERNAL_PROXY: Record = { - 'botfights.net': '/ext/botfights/', - '484.kitchen': '/ext/484-kitchen/', - 'present.l484.com': '/ext/arch-presentation/', +/** External sites proxied through nginx on dedicated ports (strips X-Frame-Options). + * Each site gets its own port so SPAs work at root — no subpath rewriting needed. */ +const EXTERNAL_PROXY_PORT: Record = { + 'botfights.net': 8901, + '484.kitchen': 8902, + 'present.l484.com': 8903, } function mustOpenInNewTab(url: string): boolean { @@ -82,10 +84,10 @@ function toEmbeddableUrl(url: string): string { const u = new URL(url) const origin = window.location.origin - // External sites proxied through nginx to strip X-Frame-Options - const extProxy = EXTERNAL_PROXY[u.hostname] - if (extProxy) { - return `${origin}${extProxy}` + // External sites proxied through nginx on dedicated ports + const extPort = EXTERNAL_PROXY_PORT[u.hostname] + if (extPort) { + return `${window.location.protocol}//${window.location.hostname}:${extPort}/` } const proxyPath = PORT_TO_PROXY[u.port] @@ -132,6 +134,42 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { } } + // NIP-07 postMessage handler — responds to nostr-request from iframe apps + async function handleNostrRequest(event: MessageEvent) { + if (!event.data || event.data.type !== 'nostr-request') return + const { id, method, params } = event.data + const source = event.source as Window | null + if (!source) return + + try { + let result: unknown + if (method === 'getPublicKey') { + const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' }) + result = res.nostr_pubkey + } else if (method === 'signEvent') { + const res = await rpcClient.call({ method: 'identity.nostr-sign', params: { event: params.event } }) + result = res + } else if (method === 'getRelays') { + result = {} + } else { + throw new Error(`Unsupported NIP-07 method: ${method}`) + } + source.postMessage({ type: 'nostr-response', id, result }, '*') + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + source.postMessage({ type: 'nostr-response', id, error: message }, '*') + } + } + + // Listen for NIP-07 requests only while an app is open + watch(isOpen, (open) => { + if (open) { + window.addEventListener('message', handleNostrRequest) + } else { + window.removeEventListener('message', handleNostrRequest) + } + }) + return { isOpen, url, diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css index 18c6fd34..e755673e 100644 --- a/neode-ui/src/style.css +++ b/neode-ui/src/style.css @@ -215,13 +215,13 @@ input[type="radio"]:active + * { .chat-mode-pill { position: absolute; - top: calc(env(safe-area-inset-top, 0px) + 1.25rem); + top: calc(env(safe-area-inset-top, 0px) + 2.25rem); right: calc(env(safe-area-inset-right, 0px) + 1.25rem); z-index: 10; } @media (min-width: 768px) { .chat-mode-pill { - top: 1.25rem; + top: 2.25rem; right: 1.25rem; } } diff --git a/neode-ui/src/utils/dummyApps.ts b/neode-ui/src/utils/dummyApps.ts index 07156ce2..8884f893 100644 --- a/neode-ui/src/utils/dummyApps.ts +++ b/neode-ui/src/utils/dummyApps.ts @@ -515,7 +515,7 @@ export const dummyApps: Record = { 'static-files': { license: 'MIT', instructions: 'Decentralized media streaming platform', - icon: 'https://indeehub.studio/favicon.ico' + icon: '/assets/img/app-icons/indeehub.ico' }, manifest: { id: 'indeedhub', @@ -545,6 +545,244 @@ export const dummyApps: Record = { }, status: ServiceStatus.Running } + }, + 'botfights': { + state: PackageState.Running, + 'static-files': { + license: 'MIT', + instructions: 'AI bot arena', + icon: '/assets/img/app-icons/botfights.svg' + }, + manifest: { + id: 'botfights', + title: 'BotFights', + version: '1.0.0', + description: { + short: 'AI bot arena — build, train, and battle autonomous agents', + long: 'BotFights is an AI bot arena where you can build, train, and battle autonomous agents. Create intelligent bots using various strategies, pit them against other players\' creations, and climb the leaderboard. Features real-time battle visualization, multiple game modes, and a growing community of bot builders.' + }, + 'release-notes': 'Initial release', + license: 'MIT', + 'wrapper-repo': '', + 'upstream-repo': '', + 'support-site': '', + 'marketing-site': 'https://botfights.net', + website: 'https://botfights.net', + 'donation-url': null + }, + installed: { + 'current-dependents': {}, + 'current-dependencies': {}, + 'last-backup': null, + 'interface-addresses': { + main: { 'tor-address': '', 'lan-address': 'https://botfights.net' } + }, + status: ServiceStatus.Running + } + }, + 'nwnn': { + state: PackageState.Running, + 'static-files': { + license: 'MIT', + instructions: 'Decentralized news aggregator', + icon: '/assets/img/app-icons/nwnn.png' + }, + manifest: { + id: 'nwnn', + title: 'Next Web News Network', + version: '1.0.0', + description: { + short: 'Decentralized news aggregator, synced from Telegram', + long: 'Next Web News Network (NWNN) is a decentralized news aggregation platform that curates and syncs content from Telegram channels. Stay informed with the latest developments in Bitcoin, decentralization, and sovereign technology. Clean reading experience with no ads or tracking.' + }, + 'release-notes': 'Initial release', + license: 'MIT', + 'wrapper-repo': '', + 'upstream-repo': '', + 'support-site': '', + 'marketing-site': 'https://nwnn.l484.com', + website: 'https://nwnn.l484.com', + 'donation-url': null + }, + installed: { + 'current-dependents': {}, + 'current-dependencies': {}, + 'last-backup': null, + 'interface-addresses': { + main: { 'tor-address': '', 'lan-address': 'https://nwnn.l484.com' } + }, + status: ServiceStatus.Running + } + }, + '484-kitchen': { + state: PackageState.Running, + 'static-files': { + license: 'MIT', + instructions: 'K484 application platform', + icon: '/assets/img/app-icons/484-kitchen.png' + }, + manifest: { + id: '484-kitchen', + title: '484 Kitchen', + version: '1.0.0', + description: { + short: 'K484 application platform', + long: '484 Kitchen is a creative application platform from the K484 collective. Explore experimental tools, interactive experiences, and cutting-edge web applications built with a focus on sovereignty and decentralization.' + }, + 'release-notes': 'Initial release', + license: 'MIT', + 'wrapper-repo': '', + 'upstream-repo': '', + 'support-site': '', + 'marketing-site': 'https://484.kitchen', + website: 'https://484.kitchen', + 'donation-url': null + }, + installed: { + 'current-dependents': {}, + 'current-dependencies': {}, + 'last-backup': null, + 'interface-addresses': { + main: { 'tor-address': '', 'lan-address': 'https://484.kitchen' } + }, + status: ServiceStatus.Running + } + }, + 'call-the-operator': { + state: PackageState.Running, + 'static-files': { + license: 'MIT', + instructions: 'Escape the Matrix', + icon: '/assets/img/app-icons/call-the-operator.png' + }, + manifest: { + id: 'call-the-operator', + title: 'Call the Operator', + version: '1.0.0', + description: { + short: 'Escape the Matrix — explore decentralized alternatives', + long: 'Call the Operator is an interactive guide to escaping the centralized matrix. Discover decentralized alternatives to mainstream services, learn about self-sovereignty, and take back control of your digital life. Beautiful dreamcore aesthetic with immersive 3D visuals.' + }, + 'release-notes': 'Initial release', + license: 'MIT', + 'wrapper-repo': '', + 'upstream-repo': '', + 'support-site': '', + 'marketing-site': 'https://cta.tx1138.com', + website: 'https://cta.tx1138.com', + 'donation-url': null + }, + installed: { + 'current-dependents': {}, + 'current-dependencies': {}, + 'last-backup': null, + 'interface-addresses': { + main: { 'tor-address': '', 'lan-address': 'https://cta.tx1138.com' } + }, + status: ServiceStatus.Running + } + }, + 'arch-presentation': { + state: PackageState.Running, + 'static-files': { + license: 'MIT', + instructions: 'Archipelago presentation', + icon: '/assets/img/app-icons/arch-presentation.png' + }, + manifest: { + id: 'arch-presentation', + title: 'Arch Presentation', + version: '1.0.0', + description: { + short: 'Archipelago: The Future of Decentralized Infrastructure', + long: 'The official Archipelago presentation deck. Learn about the vision, architecture, and roadmap of the Archipelago Bitcoin Node OS. Interactive slides showcasing the future of decentralized personal infrastructure, self-sovereign computing, and the Web5 stack.' + }, + 'release-notes': 'Initial release', + license: 'MIT', + 'wrapper-repo': '', + 'upstream-repo': '', + 'support-site': '', + 'marketing-site': 'https://present.l484.com', + website: 'https://present.l484.com', + 'donation-url': null + }, + installed: { + 'current-dependents': {}, + 'current-dependencies': {}, + 'last-backup': null, + 'interface-addresses': { + main: { 'tor-address': '', 'lan-address': 'https://present.l484.com' } + }, + status: ServiceStatus.Running + } + }, + 'syntropy-institute': { + state: PackageState.Running, + 'static-files': { + license: 'MIT', + instructions: 'Frequency analysis and therapy', + icon: '/assets/img/app-icons/syntropy-institute.png' + }, + manifest: { + id: 'syntropy-institute', + title: 'Syntropy Institute', + version: '1.0.0', + description: { + short: 'Medicine Reimagined — frequency analysis and therapy', + long: 'Syntropy Institute presents a new paradigm in health and wellness through frequency analysis and therapy. Explore cutting-edge research into bioresonance, quantum biology, and the energetic foundations of health. A bridge between ancient healing wisdom and modern technology.' + }, + 'release-notes': 'Initial release', + license: 'MIT', + 'wrapper-repo': '', + 'upstream-repo': '', + 'support-site': '', + 'marketing-site': 'https://syntropy.institute', + website: 'https://syntropy.institute', + 'donation-url': null + }, + installed: { + 'current-dependents': {}, + 'current-dependencies': {}, + 'last-backup': null, + 'interface-addresses': { + main: { 'tor-address': '', 'lan-address': 'https://syntropy.institute' } + }, + status: ServiceStatus.Running + } + }, + 't-zero': { + state: PackageState.Running, + 'static-files': { + license: 'MIT', + instructions: 'Documentary series', + icon: '/assets/img/app-icons/t-zero.png' + }, + manifest: { + id: 't-zero', + title: 'T-0', + version: '1.0.0', + description: { + short: 'Documentary series on decentralization and Bitcoin', + long: 'T-0 (Tee Minus Zero) is a documentary series exploring the intersection of decentralization, Bitcoin, and personal sovereignty. Follow the stories of builders, dreamers, and freedom advocates creating the infrastructure for a more sovereign future.' + }, + 'release-notes': 'Initial release', + license: 'MIT', + 'wrapper-repo': '', + 'upstream-repo': '', + 'support-site': '', + 'marketing-site': 'https://teeminuszero.net', + website: 'https://teeminuszero.net', + 'donation-url': null + }, + installed: { + 'current-dependents': {}, + 'current-dependencies': {}, + 'last-backup': null, + 'interface-addresses': { + main: { 'tor-address': '', 'lan-address': 'https://teeminuszero.net' } + }, + status: ServiceStatus.Running + } } } diff --git a/neode-ui/src/views/AppDetails.vue b/neode-ui/src/views/AppDetails.vue index c7bae10e..86ab5462 100644 --- a/neode-ui/src/views/AppDetails.vue +++ b/neode-ui/src/views/AppDetails.vue @@ -72,6 +72,7 @@ {{ t('common.launch') }} + @@ -144,6 +146,7 @@ + @@ -330,8 +336,8 @@ - -
+ +

{{ t('appDetails.requirements') }}

@@ -478,6 +484,20 @@ const { t } = useI18n() const appId = computed(() => route.params.id as string) +// Web-only app detection (no container — external websites) +const WEB_ONLY_APP_URLS: Record = { + 'indeedhub': 'https://archipelago.indeehub.studio', + 'botfights': 'https://botfights.net', + 'nwnn': 'https://nwnn.l484.com', + '484-kitchen': 'https://484.kitchen', + 'call-the-operator': 'https://cta.tx1138.com', + 'arch-presentation': 'https://present.l484.com', + 'syntropy-institute': 'https://syntropy.institute', + 't-zero': 'https://teeminuszero.net', +} + +const isWebOnly = computed(() => appId.value in WEB_ONLY_APP_URLS) + /** Map route/marketplace app IDs to backend package keys (container names). */ const ROUTE_TO_PACKAGE_KEY: Record = { mempool: 'mempool-web', @@ -625,7 +645,8 @@ const backButtonText = computed(() => { // Check if app has a UI interface and is running const canLaunch = computed(() => { if (!pkg.value) return false - // For dummy apps, allow launch if running (they have interface addresses) + // Web-only apps are always launchable + if (isWebOnly.value) return true // For real apps, check for UI interface const hasUI = pkg.value.manifest.interfaces?.main?.ui || pkg.value.installed?.['interface-addresses']?.main const isRunning = pkg.value.state === 'running' @@ -693,12 +714,18 @@ function goBack() { function launchApp() { if (!pkg.value) return - + const isDev = import.meta.env.DEV const id = appId.value - + + // Web-only apps — use their external URL directly + const webOnlyUrl = WEB_ONLY_APP_URLS[id] + if (webOnlyUrl) { + useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.value.manifest.title }) + return + } + // Special handling for apps with Docker containers - // TODO: Replace dummy app URLs with real URLs when apps are packaged const appUrls: Record = { 'lorabell': { dev: 'http://192.168.1.166', diff --git a/neode-ui/src/views/Apps.vue b/neode-ui/src/views/Apps.vue index 4fd2f213..91d2c755 100644 --- a/neode-ui/src/views/Apps.vue +++ b/neode-ui/src/views/Apps.vue @@ -273,7 +273,7 @@ const WEB_ONLY_APPS: Record = { 'indeedhub': { state: 'running' as PackageState, manifest: { id: 'indeedhub', title: 'Indeehub', version: '0.1.0', description: { short: 'Bitcoin documentary streaming platform', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null }, - 'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/indeedhub.png' }, + 'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/indeehub.ico' }, }, 'botfights': { state: 'running' as PackageState, diff --git a/neode-ui/src/views/Chat.vue b/neode-ui/src/views/Chat.vue index 2796ac24..de3ed0ad 100644 --- a/neode-ui/src/views/Chat.vue +++ b/neode-ui/src/views/Chat.vue @@ -1,7 +1,7 @@