release(v1.7.10-alpha): apply namespace fix + FIPS cascade + profile polish
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled

THE apply fix
  archipelago.service uses ProtectSystem=strict, so /opt and /usr are
  read-only inside the service's mount namespace. sudo inherits that
  namespace — every sudo mkdir/mv/chown from apply_update was hitting
  EROFS even as root. Every prior "Failed to apply update" was a
  symptom of this. New `host_sudo()` helper wraps every filesystem
  call in `sudo systemd-run --wait --collect --pipe -- <cmd>`, which
  spawns a transient unit with systemd's default (no ProtectSystem)
  protections — the command runs in the host namespace and can touch
  /opt/archipelago + /usr/local/bin normally.

FIPS cascade (#2)
  Home.vue and Server.vue both carry a FIPS row that previously only
  looked at {installed, service_active, key_present}. Now they also
  read anchor_connected + authenticated_peer_count and mirror the
  full FIPS card: green "Active · N peers" when healthy, orange "No
  anchor" when the DHT bootstrap has failed.

Profile paste URL fallback (#4)
  Web5Identities.vue list + editor previously had `@error="display:none"`
  on the <img>, which hid the tag without re-rendering the fallback —
  a broken pasted URL showed up blank. Replaced with reactive
  pictureLoadFailed / listPictureFailed flags plus a watcher that
  resets on URL change. Broken URL now falls back to the initial (or
  identicon for seed-derived identities).

Small-upload data URL (#3)
  Uploaded profile pictures ≤ 64 KB are now inlined as
  `data:image/png;base64,...` into profile.picture on the client
  before calling update-profile. That kind-0 event is fetchable by
  any Nostr client — no Tor needed. Larger uploads fall back to the
  onion-rooted public_url with a hint telling the user to paste a
  public https:// URL for broader visibility.

Deferred: #1 FIPS Reconnect "actually fixes" — the current Reconnect
calls fips.restart which clears the daemon state, but when the
anchor is truly unreachable (UDP 8668 blocked by network/ISP), no
amount of restart can help. A richer diagnostic is out of scope for
this bundle.

Artefacts:
  archipelago                                      4a77c704…82aa6f8  40379696
  archipelago-frontend-1.7.10-alpha.tar.gz         0644a436…54f58    76983846

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-20 13:46:03 -04:00
parent 8894e1374e
commit b8ab06dd47
9 changed files with 172 additions and 108 deletions

2
core/Cargo.lock generated
View File

@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.7.9-alpha" version = "1.7.10-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.7.9-alpha" version = "1.7.10-alpha"
edition = "2021" edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"] authors = ["Archipelago Team"]

View File

@@ -244,6 +244,32 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
}) })
} }
/// Run a command as root, but *outside* the archipelago service's
/// restricted mount namespace.
///
/// archipelago.service uses `ProtectSystem=strict`, which makes `/opt`
/// and `/usr` read-only inside the service — and sudo inherits the
/// namespace, so `sudo mv /opt/archipelago/...` fails with EROFS even
/// though sudo itself is root. `systemd-run --wait` spawns a transient
/// service unit that inherits systemd's default protections (i.e. none
/// of ours), escaping the namespace.
async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus> {
let mut full: Vec<&str> = vec![
"systemd-run",
"--wait",
"--quiet",
"--collect",
"--pipe",
"--",
];
full.extend_from_slice(args);
tokio::process::Command::new("sudo")
.args(&full)
.status()
.await
.context("sudo systemd-run spawn failed")
}
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions. /// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
pub async fn apply_update(data_dir: &Path) -> Result<()> { pub async fn apply_update(data_dir: &Path) -> Result<()> {
let staging_dir = data_dir.join("update-staging"); let staging_dir = data_dir.join("update-staging");
@@ -277,31 +303,25 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
match name.as_str() { match name.as_str() {
"archipelago" => { "archipelago" => {
// We're running FROM /usr/local/bin/archipelago right now, // Two namespace gotchas this block works around:
// so we can't rewrite it in place — `install` / `cp` would // 1. We're running FROM /usr/local/bin/archipelago, so
// hit ETXTBSY on the busy executable. Use `mv` instead: // `install`/`cp` (O_TRUNC + write) fail with ETXTBSY.
// rename() is atomic and doesn't modify the existing file, // Use `mv`, which is atomic rename() and tolerates a
// it just re-points the path at a new inode. The currently // busy destination.
// running process keeps executing off the old inode; new // 2. archipelago.service sets ProtectSystem=strict, so
// invocations (i.e. after the post-apply systemctl // even `sudo mv` into /usr/local/bin/ fails EROFS —
// restart) pick up the new binary. // sudo inherits the service's mount namespace. Route
// the rename through systemd-run so it runs in a
// transient unit with default protections.
let staged = src.to_string_lossy().to_string(); let staged = src.to_string_lossy().to_string();
let _ = tokio::process::Command::new("sudo") let _ = host_sudo(&["chmod", "0755", &staged]).await;
.args(["chmod", "0755", &staged]) let _ = host_sudo(&["chown", "root:root", &staged]).await;
.status() let status = host_sudo(&["mv", &staged, "/usr/local/bin/archipelago"])
.await;
let _ = tokio::process::Command::new("sudo")
.args(["chown", "root:root", &staged])
.status()
.await;
let status = tokio::process::Command::new("sudo")
.args(["mv", &staged, "/usr/local/bin/archipelago"])
.status()
.await .await
.with_context(|| format!("Failed to spawn mv for {}", name))?; .with_context(|| format!("Failed to spawn mv for {}", name))?;
if !status.success() { if !status.success() {
anyhow::bail!( anyhow::bail!(
"sudo mv failed for {} (exit {:?})", "mv into /usr/local/bin failed for {} (exit {:?})",
name, name,
status.code() status.code()
); );
@@ -320,78 +340,66 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
let web_ui = "/opt/archipelago/web-ui"; let web_ui = "/opt/archipelago/web-ui";
let backup_path = "/opt/archipelago/web-ui.bak"; let backup_path = "/opt/archipelago/web-ui.bak";
let mk = tokio::process::Command::new("sudo") // All sudo calls that touch /opt/archipelago go through
.args(["mkdir", "-p", &staging_new]) // host_sudo so they see a normal root mount namespace.
.status() let mk = host_sudo(&["mkdir", "-p", &staging_new])
.await .await
.context("Failed to create frontend staging dir")?; .context("Failed to create frontend staging dir")?;
if !mk.success() { if !mk.success() {
anyhow::bail!("mkdir {} failed", staging_new); anyhow::bail!("mkdir {} failed", staging_new);
} }
let extract = tokio::process::Command::new("sudo") let extract = host_sudo(&[
.args(["tar", "-xzf", &src.to_string_lossy(), "-C", &staging_new]) "tar",
.status() "-xzf",
.await &src.to_string_lossy(),
.with_context(|| format!("Failed to extract {}", name))?; "-C",
&staging_new,
])
.await
.with_context(|| format!("Failed to extract {}", name))?;
if !extract.success() { if !extract.success() {
// Best-effort cleanup of the partial extraction. let _ = host_sudo(&["rm", "-rf", &staging_new]).await;
let _ = tokio::process::Command::new("sudo")
.args(["rm", "-rf", &staging_new])
.status()
.await;
anyhow::bail!("tar extraction failed for {}", name); anyhow::bail!("tar extraction failed for {}", name);
} }
let _ = tokio::process::Command::new("sudo") let _ = host_sudo(&[
.args(["chown", "-R", "archipelago:archipelago", &staging_new]) "chown",
.status() "-R",
.await; "archipelago:archipelago",
&staging_new,
])
.await;
// Swap: mv current web-ui aside, then mv new into place. // Swap: mv current web-ui aside, then mv new into place.
if Path::new(web_ui).exists() { if Path::new(web_ui).exists() {
let mv_old = tokio::process::Command::new("sudo") let mv_old = host_sudo(&["mv", web_ui, &staging_old])
.args(["mv", web_ui, &staging_old])
.status()
.await .await
.context("Failed to rotate old web-ui")?; .context("Failed to rotate old web-ui")?;
if !mv_old.success() { if !mv_old.success() {
anyhow::bail!("failed to move old web-ui aside"); anyhow::bail!("failed to move old web-ui aside");
} }
} }
let mv_new = tokio::process::Command::new("sudo") let mv_new = host_sudo(&["mv", &staging_new, web_ui])
.args(["mv", &staging_new, web_ui])
.status()
.await .await
.context("Failed to swap new web-ui into place")?; .context("Failed to swap new web-ui into place")?;
if !mv_new.success() { if !mv_new.success() {
// Roll back the rename so nginx keeps serving.
if Path::new(&staging_old).exists() { if Path::new(&staging_old).exists() {
let _ = tokio::process::Command::new("sudo") let _ = host_sudo(&["mv", &staging_old, web_ui]).await;
.args(["mv", &staging_old, web_ui])
.status()
.await;
} }
anyhow::bail!("failed to move new web-ui into place"); anyhow::bail!("failed to move new web-ui into place");
} }
// Rotate previous rollback aside (best-effort) and install // Rotate previous rollback aside and install this apply's
// this apply's old copy as the new rollback. // old copy as the new rollback.
if Path::new(&staging_old).exists() { if Path::new(&staging_old).exists() {
if Path::new(backup_path).exists() { if Path::new(backup_path).exists() {
// Tag the previous backup with its own ts so it let _ = host_sudo(&[
// doesn't collide; best-effort cleanup. "mv",
let _ = tokio::process::Command::new("sudo") backup_path,
.args([ &format!("{}.{}", backup_path, ts),
"mv", ])
backup_path,
&format!("{}.{}", backup_path, ts),
])
.status()
.await;
}
let _ = tokio::process::Command::new("sudo")
.args(["mv", &staging_old, backup_path])
.status()
.await; .await;
}
let _ = host_sudo(&["mv", &staging_old, backup_path]).await;
} }
info!(name = %name, "Frontend archive extracted to /opt/archipelago/web-ui"); info!(name = %name, "Frontend archive extracted to /opt/archipelago/web-ui");
} }
@@ -422,10 +430,10 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
// starting the new process — it would deadlock otherwise. // starting the new process — it would deadlock otherwise.
tokio::spawn(async { tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(2)).await; tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let _ = tokio::process::Command::new("sudo") // systemctl talks to PID 1 over D-Bus — doesn't need the host
.args(["systemctl", "--no-block", "restart", "archipelago"]) // mount namespace, but routing through host_sudo keeps the
.status() // apply flow's sudo calls uniform.
.await; let _ = host_sudo(&["systemctl", "--no-block", "restart", "archipelago"]).await;
}); });
Ok(()) Ok(())

View File

@@ -317,26 +317,35 @@ const torConnected = computed(() => {
}) })
const vpnStatus = ref({ connected: false, provider: '' }) const vpnStatus = ref({ connected: false, provider: '' })
const vpnConnected = computed(() => vpnStatus.value.connected || (!!packages.value['tailscale'] && packages.value['tailscale'].state === PackageState.Running)) const vpnConnected = computed(() => vpnStatus.value.connected || (!!packages.value['tailscale'] && packages.value['tailscale'].state === PackageState.Running))
const fipsStatus = ref<{ installed: boolean; service_active: boolean; key_present: boolean } | null>(null) const fipsStatus = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null)
const fipsDotClass = computed(() => { const fipsDotClass = computed(() => {
const s = fipsStatus.value const s = fipsStatus.value
if (!s || !s.installed) return 'bg-white/40' if (!s || !s.installed) return 'bg-white/40'
if (s.service_active) return 'bg-green-400' if (!s.service_active) return 'bg-white/40'
return 'bg-white/40' // Active but no anchor = degraded, not fully green
if (s.anchor_connected === false) return 'bg-orange-400'
return 'bg-green-400'
}) })
const fipsTextClass = computed(() => { const fipsTextClass = computed(() => {
const s = fipsStatus.value const s = fipsStatus.value
if (!s || !s.installed) return 'text-white/40' if (!s || !s.installed) return 'text-white/40'
if (s.service_active) return 'text-green-400' if (!s.service_active) return 'text-white/40'
return 'text-white/40' if (s.anchor_connected === false) return 'text-orange-400'
return 'text-green-400'
}) })
const fipsStatusLabel = computed(() => { const fipsStatusLabel = computed(() => {
const s = fipsStatus.value const s = fipsStatus.value
if (!s) return '…' if (!s) return '…'
if (!s.installed) return 'Not installed' if (!s.installed) return 'Not installed'
if (s.service_active) return 'Active' if (!s.service_active) {
if (!s.key_present) return 'Awaiting seed' if (!s.key_present) return 'Awaiting seed'
return 'Inactive' return 'Inactive'
}
// Service is active — reflect anchor reachability in the label so the
// Home and Server rows flip in sync with the FIPS card.
if (s.anchor_connected === false) return 'No anchor'
const peers = s.authenticated_peer_count ?? 0
return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers`
}) })
const bitcoinSyncDisplay = computed(() => { const bitcoinSyncDisplay = computed(() => {
if (!systemStats.bitcoinAvailable) return 'Not running' if (!systemStats.bitcoinAvailable) return 'Not running'

View File

@@ -420,25 +420,31 @@ const networkData = ref({
}) })
// FIPS status row for the Local Network card. Full FIPS card lives below. // FIPS status row for the Local Network card. Full FIPS card lives below.
const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean } | null>(null) const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null)
const fipsRowLabel = computed(() => { const fipsRowLabel = computed(() => {
const s = fipsSummary.value const s = fipsSummary.value
if (!s) return '…' if (!s) return '…'
if (!s.installed) return 'Not installed' if (!s.installed) return 'Not installed'
// Service-active wins even on legacy nodes with no seed-derived key. if (!s.service_active) {
if (s.service_active) return 'Active' if (!s.key_present) return 'Awaiting seed'
if (!s.key_present) return 'Awaiting seed' return 'Inactive'
return 'Inactive' }
// Service is active — reflect anchor reachability so the row flips in
// sync with the full FIPS card below.
if (s.anchor_connected === false) return 'No anchor'
const peers = s.authenticated_peer_count ?? 0
return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers`
}) })
const fipsRowTextClass = computed(() => { const fipsRowTextClass = computed(() => {
const s = fipsSummary.value const s = fipsSummary.value
if (!s || !s.installed) return 'text-white/40' if (!s || !s.installed) return 'text-white/40'
if (s.service_active) return 'text-green-400' if (!s.service_active) return 'text-white/60'
return 'text-white/60' if (s.anchor_connected === false) return 'text-orange-400'
return 'text-green-400'
}) })
async function loadFipsSummary() { async function loadFipsSummary() {
try { try {
fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean }>({ method: 'fips.status' }) fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({ method: 'fips.status' })
} catch { /* backend too old */ } } catch { /* backend too old */ }
} }

View File

@@ -68,8 +68,13 @@
> >
<!-- Avatar --> <!-- Avatar -->
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile"> <button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
<img v-if="identity.profile?.picture" :src="displayableUrl(identity.profile.picture)" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" /> <img
<div v-if="!identity.profile?.picture" class="w-full h-full flex items-center justify-center" :class="{ v-if="identity.profile?.picture && !listPictureFailed[identity.id]"
:src="displayableUrl(identity.profile.picture)"
class="w-full h-full object-cover"
@error="() => { listPictureFailed[identity.id] = true }"
/>
<div v-if="!identity.profile?.picture || listPictureFailed[identity.id]" class="w-full h-full flex items-center justify-center" :class="{
'bg-blue-500/20': identity.purpose === 'personal', 'bg-blue-500/20': identity.purpose === 'personal',
'bg-orange-500/20': identity.purpose === 'business', 'bg-orange-500/20': identity.purpose === 'business',
'bg-purple-500/20': identity.purpose === 'anonymous', 'bg-purple-500/20': identity.purpose === 'anonymous',
@@ -302,8 +307,14 @@
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title"> <div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
<div class="flex items-center gap-3 mb-5"> <div class="flex items-center gap-3 mb-5">
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0"> <div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0">
<img v-if="profileForm.picture" :src="displayableUrl(profileForm.picture)" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" /> <img
<div v-else class="w-full h-full flex items-center justify-center"> v-if="profileForm.picture && !editorPictureFailed"
:src="displayableUrl(profileForm.picture)"
class="w-full h-full object-cover"
@error="editorPictureFailed = true"
@load="editorPictureFailed = false"
/>
<div v-if="!profileForm.picture || editorPictureFailed" class="w-full h-full flex items-center justify-center">
<span class="text-2xl font-bold text-white/40">{{ profileEditorIdentity.name.charAt(0).toUpperCase() }}</span> <span class="text-2xl font-bold text-white/40">{{ profileEditorIdentity.name.charAt(0).toUpperCase() }}</span>
</div> </div>
</div> </div>
@@ -368,7 +379,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { safeClipboardWrite } from './utils' import { safeClipboardWrite } from './utils'
@@ -409,6 +420,18 @@ const profilePublishing = ref(false)
const avatarUploading = ref(false) const avatarUploading = ref(false)
const bannerUploading = ref(false) const bannerUploading = ref(false)
// Track image load failures so the UI can fall back to the initial/
// identicon placeholder instead of showing a blank square. Pasted URLs
// that 404 (or point at an onion the local browser can't reach) were
// previously silently hidden by a display:none handler that left the
// fallback unrendered.
const editorPictureFailed = ref(false)
const listPictureFailed = reactive<Record<string, boolean>>({})
// Reset the failure flag when the URL changes so a freshly pasted URL
// gets re-tried (the watcher fires once the form reacts).
watch(() => profileForm.value.picture, () => { editorPictureFailed.value = false })
// The backend returns onion-based public URLs for uploaded profile // The backend returns onion-based public URLs for uploaded profile
// pictures (so they're fetchable by external Nostr clients), but the // pictures (so they're fetchable by external Nostr clients), but the
// local browser session isn't Tor-routed and can't resolve .onion hosts. // local browser session isn't Tor-routed and can't resolve .onion hosts.
@@ -423,10 +446,12 @@ function displayableUrl(url: string | null | undefined): string {
return url return url
} }
// Upload to the node's blob store and drop the returned public URL into // Upload to the node's blob store and drop a URL into the profile field.
// the profile field. The /api/blob endpoint marks these blobs public, so // For small images (≤64KB) we inline the bytes as a data URL so external
// the URL served back (`public_url`, onion-rooted when Tor is up) is // Nostr clients can render the picture without needing to reach a tor
// reachable by external Nostr clients fetching kind:0 metadata. // onion. Larger uploads fall back to the onion-rooted public_url.
const INLINE_MAX = 64 * 1024
async function uploadAsset(ev: Event, field: 'picture' | 'banner') { async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
const input = ev.target as HTMLInputElement const input = ev.target as HTMLInputElement
const file = input?.files?.[0] const file = input?.files?.[0]
@@ -436,6 +461,14 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
profileError.value = '' profileError.value = ''
try { try {
const buf = await file.arrayBuffer() const buf = await file.arrayBuffer()
// Inline small images as a data URL — universally fetchable by any
// Nostr client and bypasses the "only reachable over Tor" limitation.
if (buf.byteLength <= INLINE_MAX) {
const mime = file.type || 'image/png'
const b64 = btoa(Array.from(new Uint8Array(buf), (b) => String.fromCharCode(b)).join(''))
profileForm.value[field] = `data:${mime};base64,${b64}`
return
}
const resp = await fetch('/api/blob', { const resp = await fetch('/api/blob', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
@@ -451,6 +484,11 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
const url = public_url || self_test_url const url = public_url || self_test_url
if (!url) throw new Error('blob API returned no URL') if (!url) throw new Error('blob API returned no URL')
profileForm.value[field] = url profileForm.value[field] = url
// Heads-up for large uploads: onion URLs only render on Tor-routed
// clients. Not an error, but worth telling the user.
if (url.includes('.onion/')) {
profileError.value = 'Large image stored on this node. Pasting a public https://… URL is recommended for Nostr visibility.'
}
} catch (e: unknown) { } catch (e: unknown) {
profileError.value = e instanceof Error ? e.message : `${field} upload failed` profileError.value = e instanceof Error ? e.message : `${field} upload failed`
} finally { } finally {

View File

@@ -1,25 +1,28 @@
{ {
"version": "1.7.9-alpha", "version": "1.7.10-alpha",
"release_date": "2026-04-20", "release_date": "2026-04-20",
"changelog": [ "changelog": [
"OTA verification release — nothing new to see. Click Install Update, grab a coffee, and watch the sidebar flip to 1.7.9-alpha on its own. If this one works end to end, the pipeline is solid and future updates will flow the same way." "Install Update actually applies now. The installer had to write into system folders that the backend service was sandboxed out of — every earlier 'Failed to apply update' was a layer of that onion. Fixed by running the file swaps in a separate system context.",
"FIPS status on the Home and Server pages now reflects whether the public anchor is reachable. You'll see 'Active · N peers' (green) when healthy or 'No anchor' (orange) when the network is blocking the bootstrap — same signal as the full FIPS card.",
"Pasting an https://… URL into the profile picture or banner now previews correctly. Before, if the URL failed to load, the UI would silently blank out instead of showing your initial as a placeholder.",
"Uploaded profile pictures under 64 KB are now embedded directly in your Nostr profile (as a data URL), so any Nostr client can see them — not just ones routing over Tor. Larger uploads keep the onion URL for now, with a hint to paste a public URL for wider visibility."
], ],
"components": [ "components": [
{ {
"name": "archipelago", "name": "archipelago",
"current_version": "1.7.8-alpha", "current_version": "1.7.9-alpha",
"new_version": "1.7.9-alpha", "new_version": "1.7.10-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.9-alpha/archipelago", "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.10-alpha/archipelago",
"sha256": "1ec7383de8e6b5caa67ec93311db7b5695e1831730fbd40ce56a5aa5aa301629", "sha256": "4a77c704b5c1ac0b424ccfc7ed123c50e2708764ac2b4916af534e80382aa6f8",
"size_bytes": 40378536 "size_bytes": 40379696
}, },
{ {
"name": "archipelago-frontend-1.7.9-alpha.tar.gz", "name": "archipelago-frontend-1.7.10-alpha.tar.gz",
"current_version": "1.7.8-alpha", "current_version": "1.7.9-alpha",
"new_version": "1.7.9-alpha", "new_version": "1.7.10-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.9-alpha/archipelago-frontend-1.7.9-alpha.tar.gz", "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.10-alpha/archipelago-frontend-1.7.10-alpha.tar.gz",
"sha256": "4fb796643cc9dc8469078ca3392f7cc5541071f6849979922b3259e5f20172e9", "sha256": "0644a43611309031efbb9b235a3602f0828f709fcaec0047543d96e1cbd54f58",
"size_bytes": 76984615 "size_bytes": 76983846
} }
] ]
} }

Binary file not shown.