hot fixes to utc-6

This commit is contained in:
Dorian
2026-03-12 12:56:59 +00:00
parent f07ce10b1a
commit 73e0a1b74d
26 changed files with 1123 additions and 76 deletions

View File

@@ -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.

View File

@@ -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 '</head>' '<script src="/nostr-provider.js"></script></head>';` 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

View File

@@ -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 '</head>' '<script src="/nostr-provider.js"></script></head>';
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<string, number> = {
// ... 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 |

View File

@@ -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"

View File

@@ -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<serde_json::Value>,
) -> Result<serde_json::Value> {
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,
})

View File

@@ -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,
}))
}

View File

@@ -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<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {

View File

@@ -181,6 +181,42 @@ pub fn did_document_from_pubkey_hex(pubkey_hex: &str) -> Result<serde_json::Valu
}))
}
/// Generate a DID Document that includes both the Ed25519 key and a Nostr secp256k1 key.
/// The Nostr key is added as an additional verification method, formally pairing
/// the two identities so a user can use either protocol.
pub fn did_document_with_nostr(
pubkey_hex: &str,
nostr_pubkey_hex: &str,
) -> Result<serde_json::Value> {
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

View File

@@ -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<String>,
/// Nostr public key in bech32 npub format (NIP-19)
pub nostr_npub: Option<String>,
}
/// 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,
})
}

View File

@@ -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<Vec<DiscoverableNode>> {
@@ -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::<HandshakeMessage>(&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(),
});

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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 '</head>' '<script src="/nostr-provider.js"></script></head>';
}
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 '</head>' '<script src="/nostr-provider.js"></script></head>';
}
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 '</head>' '<script src="/nostr-provider.js"></script></head>';
}
# 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 '</head>' '<script src="/nostr-provider.js"></script></head>';
}
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 '</head>' '<script src="/nostr-provider.js"></script></head>';
}
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 '</head>' '<script src="/nostr-provider.js"></script></head>';
}
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 '</head>' '<script src="/nostr-provider.js"></script></head>';
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 '</head>' '<script src="/nostr-provider.js"></script></head>';
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 '</head>' '<script src="/nostr-provider.js"></script></head>';
sub_filter_once on;
}
location = /nostr-provider.js {
alias /opt/archipelago/web-ui/nostr-provider.js;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -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 });
},
},
};
})();

View File

@@ -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: {},

View File

@@ -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
}, '*')

View File

@@ -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<string, string> = {
'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<string, number> = {
'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<unknown>({ 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,

View File

@@ -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;
}
}

View File

@@ -515,7 +515,7 @@ export const dummyApps: Record<string, PackageDataEntry> = {
'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<string, PackageDataEntry> = {
},
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
}
}
}

View File

@@ -72,6 +72,7 @@
</svg>
{{ t('common.launch') }}
</button>
<template v-if="!isWebOnly">
<button
v-if="pkg.state === 'stopped'"
@click="startApp"
@@ -111,6 +112,7 @@
</svg>
{{ t('common.uninstall') }}
</button>
</template>
</div>
</div>
@@ -144,6 +146,7 @@
<!-- Uninstall Icon Button -->
<button
v-if="!isWebOnly"
@click="uninstallApp"
class="flex-shrink-0 w-10 h-10 rounded-lg bg-red-600/20 border border-red-600/40 text-red-300 hover:bg-red-600/30 transition-colors flex items-center justify-center"
:title="t('common.uninstall')"
@@ -159,6 +162,7 @@
<button
v-if="canLaunch"
@click="launchApp"
:class="isWebOnly ? 'col-span-2' : ''"
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -166,6 +170,7 @@
</svg>
{{ t('common.launch') }}
</button>
<template v-if="!isWebOnly">
<button
v-if="pkg.state === 'stopped'"
@click="startApp"
@@ -197,6 +202,7 @@
</svg>
{{ t('common.restart') }}
</button>
</template>
</div>
</div>
</div>
@@ -330,8 +336,8 @@
</div>
</div>
<!-- Requirements Card -->
<div class="glass-card p-6">
<!-- Requirements Card (hidden for web-only apps) -->
<div v-if="!isWebOnly" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.requirements') }}</h3>
<div class="space-y-3">
<div class="flex items-start gap-3">
@@ -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<string, string> = {
'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<string, string> = {
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<string, { dev: string, prod: string }> = {
'lorabell': {
dev: 'http://192.168.1.166',

View File

@@ -273,7 +273,7 @@ const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
'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,

View File

@@ -1,7 +1,7 @@
<template>
<div class="chat-fullscreen">
<!-- Close button + connection indicator (desktop: top-right pill) -->
<div class="chat-mode-pill hidden md:flex">
<div class="chat-mode-pill flex">
<button class="chat-close-btn" :aria-label="t('chat.closeAssistant')" @click="closeChat">
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
@@ -15,19 +15,12 @@
/>
</div>
<!-- Mobile back button -->
<button class="chat-mobile-back md:hidden" :aria-label="t('common.goBack')" @click="closeChat">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<!-- Loading indicator while checking availability or iframe loads -->
<!-- Loading indicator while checking availability -->
<Transition name="fade">
<div v-if="aiuiAvailable === null || (aiuiUrl && !aiuiConnected)" class="chat-loading" role="status" aria-live="polite">
<div v-if="aiuiAvailable === null" class="chat-loading" role="status" aria-live="polite">
<div class="glass-card p-8 flex flex-col items-center gap-4">
<div class="chat-loading-spinner" aria-hidden="true" />
<p class="text-sm text-white/60">{{ aiuiAvailable === null ? t('chat.loadingAssistant') : t('chat.loadingAssistant') }}</p>
<p class="text-sm text-white/60">{{ t('chat.loadingAssistant') }}</p>
</div>
</div>
</Transition>
@@ -39,13 +32,13 @@
:src="aiuiUrl"
:title="t('chat.aiAssistant')"
class="chat-iframe chat-iframe-mobile"
sandbox="allow-scripts allow-same-origin allow-forms"
allow="microphone"
style="background: transparent"
/>
<!-- Fallback when no AIUI URL configured -->
<div v-else class="chat-placeholder">
<!-- Fallback when AIUI is not deployed -->
<div v-else-if="aiuiAvailable === false" class="chat-placeholder">
<div class="chat-placeholder-inner">
<div class="chat-placeholder-icon">
<svg class="w-8 h-8 text-white/40" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -84,7 +77,7 @@ const aiuiUrl = computed(() => {
const envUrl = import.meta.env.VITE_AIUI_URL
if (envUrl) return `${envUrl}?embedded=true`
// In production, only return the URL if we've confirmed AIUI files exist
if (import.meta.env.PROD && aiuiAvailable.value === true) return '/aiui/?embedded=true'
if (import.meta.env.PROD && aiuiAvailable.value === true) return `/aiui/?embedded=true&v=${Date.now()}`
return ''
})
@@ -177,20 +170,4 @@ onBeforeUnmount(() => {
opacity: 0;
}
.chat-mobile-back {
position: absolute;
top: 0.75rem;
left: 0.75rem;
z-index: 20;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.15);
}
</style>

169
scripts/setup-aiui-server.sh Executable file
View File

@@ -0,0 +1,169 @@
#!/bin/bash
#
# Setup AIUI + Claude API proxy + FileBrowser on any Archipelago server
#
# Usage:
# ./scripts/setup-aiui-server.sh <host>
# ./scripts/setup-aiui-server.sh archipelago@192.168.1.198
# ./scripts/setup-aiui-server.sh archipelago@192.168.1.228
#
# What it does:
# 1. Deploys AIUI files (from local build)
# 2. Configures nginx Claude API proxy (direct to Anthropic with API key)
# 3. Fixes FileBrowser container (removes read-only root if needed)
# 4. Reloads nginx
#
# Prerequisites:
# - AIUI must be built locally first: cd AIUI/packages/app && VITE_BASE_PATH=/aiui/ npx vite build
# - SSH key access to target server
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
SSH_OPTS="-o StrictHostKeyChecking=no -i $SSH_KEY"
# Anthropic API key — used by all servers for AIUI Claude chat
ANTHROPIC_API_KEY="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"
TARGET_HOST="$1"
if [ -z "$TARGET_HOST" ]; then
echo "Usage: $0 <user@host>"
echo " e.g. $0 archipelago@192.168.1.198"
exit 1
fi
AIUI_DIST="$PROJECT_DIR/../AIUI/packages/app/dist"
if [ ! -f "$AIUI_DIST/index.html" ]; then
echo "ERROR: AIUI build not found at $AIUI_DIST"
echo "Build it first: cd ../AIUI/packages/app && VITE_BASE_PATH=/aiui/ npx vite build"
exit 1
fi
timestamp() { echo "[$(date +%H:%M:%S)]"; }
echo "╔════════════════════════════════════════════════════════════╗"
echo "║ Archipelago AIUI + Claude API Setup ║"
echo "║ Target: $TARGET_HOST"
echo "╚════════════════════════════════════════════════════════════╝"
# --- Step 1: Deploy AIUI files ---
echo ""
echo "$(timestamp) 📦 Deploying AIUI files..."
# Check if rsync is available on remote
if ssh $SSH_OPTS "$TARGET_HOST" "which rsync" &>/dev/null; then
rsync -avz --delete -e "ssh $SSH_OPTS" "$AIUI_DIST/" "$TARGET_HOST:/opt/archipelago/web-ui/aiui/" 2>&1 | tail -3
else
echo " rsync not available, using tar+scp..."
TMPTAR=$(mktemp /tmp/aiui-dist-XXXXX.tar.gz)
(cd "$AIUI_DIST" && tar czf "$TMPTAR" .)
scp $SSH_OPTS "$TMPTAR" "$TARGET_HOST:/tmp/aiui-dist.tar.gz"
ssh $SSH_OPTS "$TARGET_HOST" "sudo mkdir -p /opt/archipelago/web-ui/aiui && cd /opt/archipelago/web-ui/aiui && sudo tar xzf /tmp/aiui-dist.tar.gz --overwrite"
rm -f "$TMPTAR"
fi
echo " AIUI deployed."
# --- Step 2: Configure nginx Claude API proxy ---
echo ""
echo "$(timestamp) 🔧 Configuring nginx Claude API proxy..."
# Create a Python script to patch nginx config
cat << 'PYSCRIPT' > /tmp/patch-nginx-claude.py
import sys
import re
API_KEY = sys.argv[1]
with open("/etc/nginx/sites-available/archipelago") as f:
content = f.read()
# The new Claude API proxy block
new_block = '''location /aiui/api/claude/ {
if ($cookie_session = "") {
return 401 '{"error":"Unauthorized"}';
}
proxy_pass https://api.anthropic.com/;
proxy_http_version 1.1;
proxy_set_header Host api.anthropic.com;
proxy_set_header x-api-key "''' + API_KEY + '''";
proxy_set_header anthropic-version "2023-06-01";
proxy_set_header anthropic-dangerous-direct-browser-access "true";
proxy_ssl_server_name on;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_cache off;
proxy_connect_timeout 120s;
proxy_read_timeout 300s;
proxy_send_timeout 120s;
}'''
# Replace existing Claude API proxy blocks (handles both old proxy and direct patterns)
pattern = r'location /aiui/api/claude/ \{[^}]*(?:\{[^}]*\}[^}]*)*\}'
content = re.sub(pattern, new_block, content)
with open("/etc/nginx/sites-available/archipelago", "w") as f:
f.write(content)
# Verify
count = content.count("api.anthropic.com")
print(f" Patched {count // 2} Claude API proxy blocks (HTTP + HTTPS)")
PYSCRIPT
scp $SSH_OPTS /tmp/patch-nginx-claude.py "$TARGET_HOST:/tmp/patch-nginx-claude.py"
ssh $SSH_OPTS "$TARGET_HOST" "sudo python3 /tmp/patch-nginx-claude.py '$ANTHROPIC_API_KEY'"
# Test and reload nginx
echo " Testing nginx config..."
ssh $SSH_OPTS "$TARGET_HOST" "sudo nginx -t 2>&1 && sudo systemctl reload nginx && echo ' Nginx reloaded OK'" || {
echo " ERROR: nginx config test failed!"
exit 1
}
# --- Step 3: Fix FileBrowser container ---
echo ""
echo "$(timestamp) 📁 Checking FileBrowser..."
FB_STATUS=$(ssh $SSH_OPTS "$TARGET_HOST" "sudo podman inspect filebrowser 2>/dev/null | grep -oP '\"ReadonlyRootfs\":\s*\K\w+'" 2>/dev/null || echo "not_found")
if [ "$FB_STATUS" = "true" ]; then
echo " FileBrowser has read-only root — recreating..."
ssh $SSH_OPTS "$TARGET_HOST" "
sudo podman stop filebrowser 2>/dev/null
sudo podman rm filebrowser 2>/dev/null
sudo mkdir -p /var/lib/archipelago/filebrowser
sudo podman run -d --name filebrowser --restart=always \
-p 8083:80 \
-v /var/lib/archipelago/filebrowser:/srv \
filebrowser/filebrowser:v2.27.0
" 2>&1 | tail -2
echo " FileBrowser recreated."
elif [ "$FB_STATUS" = "not_found" ]; then
echo " FileBrowser not found — creating..."
ssh $SSH_OPTS "$TARGET_HOST" "
sudo mkdir -p /var/lib/archipelago/filebrowser
sudo podman run -d --name filebrowser --restart=always \
-p 8083:80 \
-v /var/lib/archipelago/filebrowser:/srv \
filebrowser/filebrowser:v2.27.0
" 2>&1 | tail -2
echo " FileBrowser created."
else
echo " FileBrowser OK (ReadonlyRootfs: $FB_STATUS)"
fi
# --- Step 4: Verify ---
echo ""
echo "$(timestamp) ✅ Verification..."
ssh $SSH_OPTS "$TARGET_HOST" "
echo \" AIUI index: \$(ls -la /opt/archipelago/web-ui/aiui/index.html 2>/dev/null | awk '{print \$6,\$7,\$8}')\"
echo \" FileBrowser: \$(sudo podman ps --format '{{.Names}} {{.Status}}' | grep filebrowser)\"
echo \" Nginx: \$(systemctl is-active nginx)\"
echo \" Backend: \$(systemctl is-active archipelago)\"
echo \" Claude API test: \$(curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost/aiui/api/claude/v1/messages -H 'Content-Type: application/json' -H 'Cookie: session=test' -d '{\"model\":\"claude-sonnet-4-20250514\",\"max_tokens\":5,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}')\"
"
echo ""
echo "$(timestamp) Done! Server configured."
echo " Access: http://$(echo $TARGET_HOST | cut -d@ -f2)"