Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19e01cd5de |
@@ -2,18 +2,6 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v1.8.19-alpha (2026-09-28)
|
||||
|
||||
- Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.
|
||||
- Embedded AIUI now stays transparent so the dashboard background appears once.
|
||||
- AIUI background fixes are now included reliably in OTA updates and fresh installations.
|
||||
|
||||
## v1.8.18-alpha (2026-09-18)
|
||||
|
||||
- Framework startup prioritizes Bitcoin and LND before unrelated containers, and unavailable LND balances remain unavailable instead of appearing as false zeroes.
|
||||
- Cashu Receive guides unseeded wallets through recovery-phrase setup, with shorter backup guidance and a single-column layout.
|
||||
- Added live Framework verification for automatic LND unlock, native balance preservation, Cashu address registration, and proof preservation.
|
||||
|
||||
## v1.8.17-alpha (2026-09-15)
|
||||
|
||||
- Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.
|
||||
|
||||
@@ -46,14 +46,13 @@ interface RateBucket {
|
||||
|
||||
const rateBuckets = new Map<string, RateBucket>()
|
||||
|
||||
// Vite imports this module during builds too; cleanup must not keep the
|
||||
// process alive once compilation has finished.
|
||||
// Clean up stale buckets every 5 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, bucket] of rateBuckets) {
|
||||
if (now > bucket.resetAt) rateBuckets.delete(key)
|
||||
}
|
||||
}, 5 * 60_000).unref()
|
||||
}, 5 * 60_000)
|
||||
|
||||
function getClientIp(req: IncomingMessage): string {
|
||||
return req.socket.remoteAddress ?? 'unknown'
|
||||
|
||||
@@ -33,7 +33,6 @@ const PWA_CACHE_VERSION = '2'
|
||||
// Only embedded when explicitly requested via ?embedded param
|
||||
const _embeddedFlag = new URLSearchParams(window.location.search).has('embedded')
|
||||
;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = _embeddedFlag
|
||||
document.documentElement.classList.toggle('aiui-embedded', _embeddedFlag)
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<div
|
||||
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
|
||||
:class="[]"
|
||||
:style="isEmbedded
|
||||
? { background: 'transparent' }
|
||||
: isDark
|
||||
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
|
||||
:style="isDark
|
||||
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
|
||||
: isEmbedded
|
||||
? { background: 'transparent' }
|
||||
: { backgroundColor: '#f5f4f1' }"
|
||||
>
|
||||
<div v-if="isDark && !isEmbedded" class="absolute inset-0 pointer-events-none bg-black/20" />
|
||||
<div v-if="isDark" class="absolute inset-0 pointer-events-none bg-black/20" />
|
||||
|
||||
<!-- Desktop layout -->
|
||||
<div
|
||||
|
||||
@@ -57,8 +57,12 @@ body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
/* Standalone canvas fallback. Embedded mode overrides this below so
|
||||
Archy's wallpaper remains visible through the iframe. */
|
||||
/* Every page paints its own explicit background (bg-[#0a0a0a] / bg-[#faf9f6])
|
||||
EXCEPT the embedded Chat page, which intentionally goes transparent so
|
||||
Archy's own dark chrome can show behind it (Chat.vue's iframe host). With
|
||||
no background-color here, "transparent" fell through to the browser's
|
||||
default white canvas instead. Match the theme's own dark/light default so
|
||||
nothing above this ever needs to guess. */
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
|
||||
@@ -66,19 +70,6 @@ html.light body {
|
||||
background-color: #faf9f6;
|
||||
}
|
||||
|
||||
/* The host owns the wallpaper when AIUI is embedded. The document canvas
|
||||
must be transparent too, otherwise it hides the host behind ChatPage. */
|
||||
html.aiui-embedded {
|
||||
/* Match Archy's dark canvas scheme. Browsers otherwise give an iframe
|
||||
with a different scheme an opaque canvas despite transparent CSS. */
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html.aiui-embedded,
|
||||
html.aiui-embedded body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ===== DARK MODE GLASSMORPHISM — from Archy ===== */
|
||||
|
||||
@layer components {
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.19-alpha"
|
||||
version = "1.8.17-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.19-alpha"
|
||||
version = "1.8.17-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
|
||||
@@ -162,11 +162,25 @@ impl ApiHandler {
|
||||
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
|
||||
),
|
||||
)),
|
||||
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
|
||||
Ok(content_server::ServeResult::NotFound) => Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
)),
|
||||
// A server-side failure is NOT "not found": reporting it as a 404
|
||||
// hid an unreadable file behind a silent, unlogged response, and a
|
||||
// buyer's client re-sends a 404 over another transport. 5xx it, and
|
||||
// say why in the journal.
|
||||
Err(e) => {
|
||||
tracing::warn!(content_id = %content_id, "content request failed: {e:#}");
|
||||
Ok(build_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"The seller could not read this file right now. You have not been charged."}"#,
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,11 @@ const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-cata
|
||||
/// Best-effort reclaim of an ecash payment token that was minted but the sale
|
||||
/// didn't complete (seller unreachable or couldn't redeem it), so the buyer
|
||||
/// doesn't lose the value. For Fedimint the spender can reissue its own
|
||||
/// un-redeemed notes; for Cashu the proofs are received back. Fails silently if
|
||||
/// the seller already claimed the token (then the value is genuinely gone).
|
||||
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) {
|
||||
/// un-redeemed notes; for Cashu the proofs are received back. Returns whether
|
||||
/// the value came back: false if the seller already claimed the token (then
|
||||
/// the value is genuinely gone), so callers never tell the buyer they were
|
||||
/// refunded when they weren't.
|
||||
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) -> bool {
|
||||
let res = match backend {
|
||||
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
||||
.await
|
||||
@@ -32,13 +34,29 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &
|
||||
_ => ecash::receive_token(data_dir, token).await,
|
||||
};
|
||||
match res {
|
||||
Ok(sats) => tracing::info!(
|
||||
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
||||
claimed it): {e:#}"
|
||||
),
|
||||
Ok(sats) => {
|
||||
tracing::info!(
|
||||
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
||||
);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
||||
claimed it): {e:#}"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What to tell the buyer about their payment after a failed sale.
|
||||
fn refund_note(reclaimed: bool) -> &'static str {
|
||||
if reclaimed {
|
||||
"Your ecash was refunded to your wallet."
|
||||
} else {
|
||||
"The seller had already claimed the payment, so it could not be refunded \
|
||||
automatically — contact the seller."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,9 +582,13 @@ impl RpcHandler {
|
||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||
// The token was already minted/spent — reclaim it so the buyer
|
||||
// doesn't lose the value when the seller was simply unreachable.
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
let reclaimed =
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Your ecash was refunded to your wallet. Please try again."
|
||||
"error": format!(
|
||||
"Could not reach the peer over mesh or Tor — it may be offline. {} Please try again.",
|
||||
refund_note(reclaimed)
|
||||
)
|
||||
}));
|
||||
}
|
||||
};
|
||||
@@ -592,15 +614,19 @@ impl RpcHandler {
|
||||
);
|
||||
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
||||
// their funds (the spent-but-unredeemed-notes case the user hit).
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
let reclaimed =
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
// The 402 body is generic, so don't assert a cause — a seller that
|
||||
// redeemed the token and then failed to deliver also lands here.
|
||||
let hint = match used_backend {
|
||||
"fedimint" => "the seller isn't in the same Fedimint federation as you",
|
||||
_ => "the seller doesn't accept your Cashu mint",
|
||||
"fedimint" => "the seller may not be in the same Fedimint federation as you",
|
||||
_ => "the seller may not accept your Cashu mint",
|
||||
};
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!(
|
||||
"Payment rejected by the seller — {hint}. Your ecash was refunded to \
|
||||
your wallet. Try the other ecash type, or use a shared mint/federation."
|
||||
"Payment not accepted by the seller — {hint}. {} Try the other ecash \
|
||||
type, or use a shared mint/federation.",
|
||||
refund_note(reclaimed)
|
||||
)
|
||||
}));
|
||||
}
|
||||
@@ -609,9 +635,10 @@ impl RpcHandler {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
let reclaimed =
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Peer returned an error ({status}). Your ecash was refunded to your wallet.")
|
||||
"error": format!("Peer returned an error ({status}). {}", refund_note(reclaimed))
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -678,12 +705,28 @@ impl RpcHandler {
|
||||
.unwrap_or("download")
|
||||
.to_string();
|
||||
let dir = self.config.data_dir.join("filebrowser").join(folder);
|
||||
match crate::container::filebrowser::save_new_file(&dir, &base, &bytes).await {
|
||||
Ok(path) => tracing::info!("paid download: filed into {}", path.display()),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: filing into {} failed (non-fatal): {e:#}",
|
||||
dir.display()
|
||||
),
|
||||
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
|
||||
tracing::warn!("paid download: cannot create {}: {e}", dir.display());
|
||||
} else {
|
||||
// Don't clobber an existing file of the same name: "x.jpg"
|
||||
// → "x (2).jpg" etc.
|
||||
let mut target = dir.join(&base);
|
||||
let (stem, ext) = match base.rsplit_once('.') {
|
||||
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
|
||||
_ => (base.clone(), String::new()),
|
||||
};
|
||||
let mut n = 2;
|
||||
while target.exists() {
|
||||
target = dir.join(format!("{stem} ({n}){ext}"));
|
||||
n += 1;
|
||||
}
|
||||
match tokio::fs::write(&target, &bytes).await {
|
||||
Ok(()) => tracing::info!("paid download: filed into {}", target.display()),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: filing into {} failed (non-fatal): {e}",
|
||||
target.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! starting the container with `--config /data/.filebrowser.json`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::update::host_sudo;
|
||||
@@ -117,123 +117,6 @@ fn shell_quote(s: &str) -> String {
|
||||
s.replace('\'', "'\\''")
|
||||
}
|
||||
|
||||
/// Save `bytes` into FileBrowser's storage as a new file in `dir`, named
|
||||
/// `name` or, if that's taken, `name (2)`, `name (3)`… Never overwrites.
|
||||
/// Returns the path written.
|
||||
///
|
||||
/// FileBrowser's folders belong to its rootless container range (host uid
|
||||
/// 100000, mode 755), so this service — host uid 1000, outside that range —
|
||||
/// can read them but not write into them, and filing a purchase into Files
|
||||
/// failed with EACCES (2026-09-29). When a direct write is refused, the file
|
||||
/// is written through `podman unshare`, where that range is ours, and given
|
||||
/// the folder's owner so FileBrowser manages it like its own uploads.
|
||||
pub async fn save_new_file(dir: &Path, name: &str, bytes: &[u8]) -> Result<PathBuf> {
|
||||
save_new_file_with(dir, name, bytes, write_via_userns).await
|
||||
}
|
||||
|
||||
async fn save_new_file_with<F, Fut>(
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
fallback: F,
|
||||
) -> Result<PathBuf>
|
||||
where
|
||||
F: FnOnce(PathBuf, Vec<u8>) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
let target = unused_name(dir, name);
|
||||
match write_direct(dir, &target, bytes).await {
|
||||
Ok(()) => Ok(target),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
fallback(target.clone(), bytes.to_vec())
|
||||
.await
|
||||
.with_context(|| format!("writing {} via podman unshare", target.display()))?;
|
||||
Ok(target)
|
||||
}
|
||||
Err(e) => Err(e).with_context(|| format!("writing {}", target.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// `dir/name`, or the first free `dir/stem (n).ext` from n = 2.
|
||||
fn unused_name(dir: &Path, name: &str) -> PathBuf {
|
||||
let mut target = dir.join(name);
|
||||
let (stem, ext) = match name.rsplit_once('.') {
|
||||
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
|
||||
_ => (name.to_string(), String::new()),
|
||||
};
|
||||
let mut n = 2;
|
||||
while target.exists() {
|
||||
target = dir.join(format!("{stem} ({n}){ext}"));
|
||||
n += 1;
|
||||
}
|
||||
target
|
||||
}
|
||||
|
||||
async fn write_direct(dir: &Path, target: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
fs::create_dir_all(dir).await?;
|
||||
let mut f = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(target)
|
||||
.await?;
|
||||
let written = async {
|
||||
f.write_all(bytes).await?;
|
||||
f.flush().await
|
||||
}
|
||||
.await;
|
||||
if written.is_err() {
|
||||
let _ = fs::remove_file(target).await;
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
/// Write `bytes` (piped on stdin) to `target` from inside the rootless user
|
||||
/// namespace. It goes to a temp file first and is hard-linked into place, so
|
||||
/// FileBrowser never sees a partial file and an existing file is never
|
||||
/// replaced (`ln` refuses an existing name).
|
||||
async fn write_via_userns(target: PathBuf, bytes: Vec<u8>) -> Result<()> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
const SCRIPT: &str = r#"set -eu
|
||||
dst=$1
|
||||
dir=$(dirname -- "$dst")
|
||||
if [ ! -d "$dir" ]; then
|
||||
mkdir -- "$dir"
|
||||
chown --reference="$(dirname -- "$dir")" -- "$dir"
|
||||
fi
|
||||
tmp="$dir/.archy-saving.$$"
|
||||
trap 'rm -f -- "$tmp"' EXIT
|
||||
cat > "$tmp"
|
||||
chown --reference="$dir" -- "$tmp"
|
||||
chmod 0644 -- "$tmp"
|
||||
ln -- "$tmp" "$dst"
|
||||
"#;
|
||||
let mut child = tokio::process::Command::new("podman")
|
||||
.args(["unshare", "sh", "-c", SCRIPT, "sh"])
|
||||
.arg(&target)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.context("Failed to run podman unshare")?;
|
||||
let mut stdin = child.stdin.take().context("podman unshare stdin")?;
|
||||
let fed = stdin.write_all(&bytes).await;
|
||||
drop(stdin);
|
||||
let out = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.context("Failed to wait for podman unshare")?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman unshare exited with {}: {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
fed.context("Failed to pipe the file to podman unshare")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -268,95 +151,4 @@ mod tests {
|
||||
let second = ensure_config(&paths).await.unwrap();
|
||||
assert_eq!(second, EnsureOutcome::Unchanged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unused_name_numbers_duplicates_and_keeps_the_extension() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
assert_eq!(unused_name(d, "song.mp3"), d.join("song.mp3"));
|
||||
std::fs::write(d.join("song.mp3"), b"").unwrap();
|
||||
assert_eq!(unused_name(d, "song.mp3"), d.join("song (2).mp3"));
|
||||
std::fs::write(d.join("song (2).mp3"), b"").unwrap();
|
||||
assert_eq!(unused_name(d, "song.mp3"), d.join("song (3).mp3"));
|
||||
std::fs::write(d.join("README"), b"").unwrap();
|
||||
assert_eq!(unused_name(d, "README"), d.join("README (2)"));
|
||||
std::fs::write(d.join(".hidden"), b"").unwrap();
|
||||
assert_eq!(unused_name(d, ".hidden"), d.join(".hidden (2)"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_new_file_writes_directly_into_a_writable_folder() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let music = dir.path().join("Music");
|
||||
let path = save_new_file_with(&music, "a.mp3", b"abc", |_, _| async {
|
||||
anyhow::bail!("fallback must not run")
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(path, music.join("a.mp3"));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"abc");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_new_file_never_overwrites_an_existing_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("a.mp3"), b"original").unwrap();
|
||||
let path = save_new_file_with(dir.path(), "a.mp3", b"new", |_, _| async {
|
||||
anyhow::bail!("fallback must not run")
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(path, dir.path().join("a (2).mp3"));
|
||||
assert_eq!(
|
||||
std::fs::read(dir.path().join("a.mp3")).unwrap(),
|
||||
b"original"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression (2026-09-29): filing a purchase into a FileBrowser folder
|
||||
/// owned by the container's uid range failed with EACCES. A refused
|
||||
/// write must go through the user-namespace fallback, with the same
|
||||
/// target and bytes.
|
||||
#[tokio::test]
|
||||
async fn a_refused_write_goes_through_the_userns_fallback() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let music = dir.path().join("Music");
|
||||
std::fs::create_dir(&music).unwrap();
|
||||
std::fs::set_permissions(&music, std::fs::Permissions::from_mode(0o555)).unwrap();
|
||||
if std::fs::File::create(music.join("probe")).is_ok() {
|
||||
return; // running as root: mode bits don't refuse the write
|
||||
}
|
||||
|
||||
let seen = std::sync::Mutex::new(None);
|
||||
let path = save_new_file_with(&music, "a.mp3", b"abc", |target, bytes| {
|
||||
*seen.lock().unwrap() = Some((target, bytes));
|
||||
async { Ok(()) }
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(path, music.join("a.mp3"));
|
||||
assert_eq!(
|
||||
seen.into_inner().unwrap(),
|
||||
Some((music.join("a.mp3"), b"abc".to_vec()))
|
||||
);
|
||||
std::fs::set_permissions(&music, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_fallback_is_reported() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap();
|
||||
if std::fs::File::create(dir.path().join("probe")).is_ok() {
|
||||
return;
|
||||
}
|
||||
let err = save_new_file_with(dir.path(), "a.mp3", b"abc", |_, _| async {
|
||||
anyhow::bail!("no podman")
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(format!("{err:#}").contains("no podman"));
|
||||
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,110 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::fs;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
const CATALOG_FILE: &str = "content/catalog.json";
|
||||
const CONTENT_DIR: &str = "content/files";
|
||||
|
||||
/// How long a redeemed payment token keeps entitling its buyer to re-fetch the
|
||||
/// item it paid for. Long enough to cover a buyer's transport fallback (FIPS →
|
||||
/// Tor re-sends the same request, token included) and a manual retry; short
|
||||
/// enough that the ledger stays tiny and a leaked token isn't a standing pass.
|
||||
const REDEMPTION_TTL: Duration = Duration::from_secs(600);
|
||||
|
||||
/// One ledger slot per payment token (keyed by its SHA-256 — the raw bearer
|
||||
/// token is never held here). The inner mutex serialises verification of the
|
||||
/// same token; its value is the content id the token was redeemed for.
|
||||
struct RedemptionSlot {
|
||||
created_at: Instant,
|
||||
redeemed_for: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
static REDEMPTIONS: LazyLock<Mutex<HashMap<String, RedemptionSlot>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Decide whether `token` pays for `content_id`, redeeming it at most once.
|
||||
///
|
||||
/// Payment tokens are single-use: verifying one swaps its proofs at the mint,
|
||||
/// so a second verification of the same token always fails "already spent".
|
||||
/// A buyer's HTTP client can legitimately send the same request twice — its
|
||||
/// FIPS attempt gets a 404/5xx and it re-sends over Tor — and without this
|
||||
/// the seller redeemed the token on the first request, then answered the
|
||||
/// retry `402 Payment required`: money taken, file never delivered.
|
||||
///
|
||||
/// So the first verification that succeeds is remembered (per token, per
|
||||
/// item, for [`REDEMPTION_TTL`]) and later requests for the same item present
|
||||
/// the same token are authorised without touching the mint again. Concurrent
|
||||
/// requests with one token queue on the slot so only one runs `verify`.
|
||||
/// A failed verification is not remembered — the slot is dropped so garbage
|
||||
/// tokens can't accumulate and a legitimate retry gets a fresh attempt.
|
||||
async fn authorize_payment<F, Fut>(token: &str, content_id: &str, verify: F) -> bool
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = bool>,
|
||||
{
|
||||
let key = hex::encode(Sha256::digest(token.as_bytes()));
|
||||
let redeemed_for = {
|
||||
let mut ledger = REDEMPTIONS.lock().await;
|
||||
ledger.retain(|_, s| s.created_at.elapsed() < REDEMPTION_TTL);
|
||||
ledger
|
||||
.entry(key.clone())
|
||||
.or_insert_with(|| RedemptionSlot {
|
||||
created_at: Instant::now(),
|
||||
redeemed_for: Arc::new(Mutex::new(None)),
|
||||
})
|
||||
.redeemed_for
|
||||
.clone()
|
||||
};
|
||||
|
||||
let mut state = redeemed_for.lock().await;
|
||||
if state.as_deref() == Some(content_id) {
|
||||
debug!(
|
||||
"Payment token already redeemed for '{}' — serving without re-verifying",
|
||||
content_id
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if verify().await {
|
||||
*state = Some(content_id.to_string());
|
||||
return true;
|
||||
}
|
||||
// Keep a slot that already holds a redemption (this token paid for a
|
||||
// different item); drop one that never verified anything.
|
||||
let never_redeemed = state.is_none();
|
||||
drop(state);
|
||||
if never_redeemed {
|
||||
REDEMPTIONS.lock().await.remove(&key);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Confirm the node can actually hand the file over: it exists and this
|
||||
/// process may read it. Must run BEFORE a payment is redeemed — a paid buyer
|
||||
/// who then hits a read error has lost their token for nothing (2026-09-18:
|
||||
/// filebrowser-owned `0640` files the node's service user couldn't open; the
|
||||
/// stat calls passed, `fs::read` failed after the swap, the buyer got a 404).
|
||||
/// Reading a byte (not just opening) also rejects a directory.
|
||||
async fn ensure_servable(file_path: &Path) -> Result<()> {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut file = fs::File::open(file_path)
|
||||
.await
|
||||
.with_context(|| format!("content file {} is not readable", file_path.display()))?;
|
||||
let mut probe = [0u8; 1];
|
||||
file.read(&mut probe)
|
||||
.await
|
||||
.with_context(|| format!("content file {} cannot be read", file_path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContentItem {
|
||||
pub id: String,
|
||||
@@ -296,6 +393,31 @@ pub async fn serve_content(
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the file can be served BEFORE any payment is redeemed. The gate
|
||||
// below swaps the buyer's token at the mint; failing to hand over the file
|
||||
// after that takes their money and delivers nothing.
|
||||
let file_path = content_file_path(data_dir, item);
|
||||
if !file_path.exists() {
|
||||
// The catalog entry survived (it's a separate JSON file) but its
|
||||
// backing file is gone — most likely lost in an unrelated data-dir
|
||||
// reset (a shared filebrowser file, 2026-07-01: two catalog entries
|
||||
// outlived a filebrowser reinstall that wiped the files themselves).
|
||||
// Leaving the entry in place would keep advertising it as available
|
||||
// to every peer forever, each hitting the exact same dead end this
|
||||
// one just did. Prune it so it stops being offered.
|
||||
warn!(
|
||||
content_id = %id,
|
||||
filename = %item.filename,
|
||||
"content catalog entry's file is missing on disk — pruning the stale entry"
|
||||
);
|
||||
prune_missing_content_entry(data_dir, id).await;
|
||||
return Ok(ServeResult::NotFound);
|
||||
}
|
||||
if let Err(e) = ensure_servable(&file_path).await {
|
||||
warn!(content_id = %id, "cannot serve content (payment not taken): {e:#}");
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Check access control
|
||||
if !owner_session {
|
||||
match &item.access {
|
||||
@@ -309,7 +431,10 @@ pub async fn serve_content(
|
||||
if let Some(token) = payment_token {
|
||||
if (method_accepted(&item.access, "ecash")
|
||||
|| method_accepted(&item.access, "fedimint"))
|
||||
&& verify_payment_token(data_dir, token, *price_sats).await
|
||||
&& authorize_payment(token, id, || {
|
||||
verify_payment_token(data_dir, token, *price_sats)
|
||||
})
|
||||
.await
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
@@ -336,24 +461,6 @@ pub async fn serve_content(
|
||||
}
|
||||
}
|
||||
|
||||
let file_path = content_file_path(data_dir, item);
|
||||
if !file_path.exists() {
|
||||
// The catalog entry survived (it's a separate JSON file) but its
|
||||
// backing file is gone — most likely lost in an unrelated data-dir
|
||||
// reset (a shared filebrowser file, 2026-07-01: two catalog entries
|
||||
// outlived a filebrowser reinstall that wiped the files themselves).
|
||||
// Leaving the entry in place would keep advertising it as available
|
||||
// to every peer forever, each hitting the exact same dead end this
|
||||
// one just did. Prune it so it stops being offered.
|
||||
warn!(
|
||||
content_id = %id,
|
||||
filename = %item.filename,
|
||||
"content catalog entry's file is missing on disk — pruning the stale entry"
|
||||
);
|
||||
prune_missing_content_entry(data_dir, id).await;
|
||||
return Ok(ServeResult::NotFound);
|
||||
}
|
||||
|
||||
let metadata = fs::metadata(&file_path)
|
||||
.await
|
||||
.context("Failed to read file metadata")?;
|
||||
@@ -725,3 +832,182 @@ mod prune_missing_content_tests {
|
||||
assert_eq!(reloaded.items[0].id, "present-item");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod paid_delivery_tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// A verifier that counts how often it actually runs.
|
||||
fn counting(
|
||||
calls: &Arc<AtomicUsize>,
|
||||
result: bool,
|
||||
) -> impl FnOnce() -> std::future::Ready<bool> {
|
||||
let calls = calls.clone();
|
||||
move || {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
std::future::ready(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_token_is_served_without_redeeming_twice() {
|
||||
// The 2026-09-18 incident: the buyer's client re-sent the same request
|
||||
// over Tor after the seller had already redeemed the token, and the
|
||||
// second verification ("already spent") turned into a 402.
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
assert!(authorize_payment("tok-replay", "item-a", counting(&calls, true)).await);
|
||||
assert!(authorize_payment("tok-replay", "item-a", counting(&calls, true)).await);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1, "mint must be hit once");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_requests_with_one_token_redeem_once() {
|
||||
// FIPS attempt still in flight when the Tor fallback arrives.
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let slow = |calls: Arc<AtomicUsize>| {
|
||||
move || async move {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
true
|
||||
}
|
||||
};
|
||||
let (a, b) = tokio::join!(
|
||||
authorize_payment("tok-concurrent", "item-a", slow(calls.clone())),
|
||||
authorize_payment("tok-concurrent", "item-a", slow(calls.clone())),
|
||||
);
|
||||
assert!(a && b, "both requests must be served");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_verification_is_not_remembered() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
assert!(!authorize_payment("tok-bad", "item-a", counting(&calls, false)).await);
|
||||
// A retry gets a fresh attempt — and can succeed (e.g. mint was down).
|
||||
assert!(authorize_payment("tok-bad", "item-a", counting(&calls, true)).await);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
let ledger = REDEMPTIONS.lock().await;
|
||||
let key = hex::encode(Sha256::digest(b"tok-bad"));
|
||||
assert!(ledger.contains_key(&key), "successful redemption is kept");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_verification_leaves_no_ledger_entry() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
assert!(!authorize_payment("tok-garbage", "item-a", counting(&calls, false)).await);
|
||||
let key = hex::encode(Sha256::digest(b"tok-garbage"));
|
||||
assert!(
|
||||
!REDEMPTIONS.lock().await.contains_key(&key),
|
||||
"garbage tokens must not accumulate"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_redeemed_for_one_item_does_not_unlock_another() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
assert!(authorize_payment("tok-cross", "item-a", counting(&calls, true)).await);
|
||||
// Item B is verified on its own merits (the real mint would say
|
||||
// "already spent"); it must not ride on item A's redemption…
|
||||
assert!(!authorize_payment("tok-cross", "item-b", counting(&calls, false)).await);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
// …and failing there must not revoke what the token already paid for.
|
||||
assert!(authorize_payment("tok-cross", "item-a", counting(&calls, true)).await);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
fn paid_item(id: &str, filename: &str) -> ContentItem {
|
||||
ContentItem {
|
||||
id: id.to_string(),
|
||||
filename: filename.to_string(),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
size_bytes: 4,
|
||||
description: String::new(),
|
||||
access: AccessControl::Paid {
|
||||
price_sats: 10,
|
||||
accepted: vec!["ecash".to_string()],
|
||||
},
|
||||
availability: Availability::AllPeers,
|
||||
added_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn unreadable_paid_file_errors_before_any_payment_is_redeemed() {
|
||||
// Filebrowser-owned 0640 files the node's service user can't read:
|
||||
// stat() succeeds, read() fails. That must surface as an error BEFORE
|
||||
// the token is verified — never after the swap has taken the money.
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let data_dir = dir.path();
|
||||
save_catalog(
|
||||
data_dir,
|
||||
&ContentCatalog {
|
||||
items: vec![paid_item("locked", "locked.mp3")],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let files = data_dir.join("content").join("files");
|
||||
tokio::fs::create_dir_all(&files).await.unwrap();
|
||||
let file = files.join("locked.mp3");
|
||||
tokio::fs::write(&file, b"data").await.unwrap();
|
||||
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
if std::fs::File::open(&file).is_ok() {
|
||||
return; // running as root: permissions can't be enforced here
|
||||
}
|
||||
|
||||
// A token that would fail verification if it were reached: getting
|
||||
// PaymentRequired here would mean the gate ran before the file check.
|
||||
let result = serve_content(
|
||||
data_dir,
|
||||
"locked",
|
||||
Some("cashuBnot-a-real-token"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"unreadable file must be a server error, not 402/404"
|
||||
);
|
||||
let key = hex::encode(Sha256::digest(b"cashuBnot-a-real-token"));
|
||||
assert!(
|
||||
!REDEMPTIONS.lock().await.contains_key(&key),
|
||||
"no redemption may be attempted for an unservable file"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readable_paid_file_with_bad_token_still_requires_payment() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let data_dir = dir.path();
|
||||
save_catalog(
|
||||
data_dir,
|
||||
&ContentCatalog {
|
||||
items: vec![paid_item("ok", "ok.mp3")],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let files = data_dir.join("content").join("files");
|
||||
tokio::fs::create_dir_all(&files).await.unwrap();
|
||||
tokio::fs::write(files.join("ok.mp3"), b"data").await.unwrap();
|
||||
|
||||
let result = serve_content(
|
||||
data_dir,
|
||||
"ok",
|
||||
Some("cashuBnot-a-real-token-2"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::PaymentRequired(10)));
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.19-alpha",
|
||||
"version": "1.8.17-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.19-alpha",
|
||||
"version": "1.8.17-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.8.19-alpha",
|
||||
"version": "1.8.17-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -362,30 +362,6 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.8.19-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.19-alpha</span>
|
||||
<span class="text-xs text-white/40">September 28, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.</p>
|
||||
<p>Embedded AIUI now stays transparent so the dashboard background appears once.</p>
|
||||
<p>AIUI background fixes are now included reliably in OTA updates and fresh installations.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.18-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.18-alpha</span>
|
||||
<span class="text-xs text-white/40">September 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Framework startup prioritizes Bitcoin and LND before unrelated containers, and unavailable LND balances remain unavailable instead of appearing as false zeroes.</p>
|
||||
<p>Cashu Receive guides unseeded wallets through recovery-phrase setup, with shorter backup guidance and a single-column layout.</p>
|
||||
<p>Added live Framework verification for automatic LND unlock, native balance preservation, Cashu address registration, and proof preservation.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.17-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
+18
-17
@@ -1,29 +1,30 @@
|
||||
{
|
||||
"changelog": [
|
||||
"Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.",
|
||||
"Embedded AIUI now stays transparent so the dashboard background appears once.",
|
||||
"AIUI background fixes are now included reliably in OTA updates and fresh installations."
|
||||
"Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.",
|
||||
"Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.",
|
||||
"Large payment backlogs are fetched from newest to oldest with a saved cursor, so polling can resume after interruptions or page limits. Payments sharing the same timestamp remain reachable.",
|
||||
"Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.19-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.19-alpha/archipelago",
|
||||
"current_version": "1.8.17-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.19-alpha",
|
||||
"sha256": "bb500d02567ad16179179d43138cc6fdafee680835262026eeaa7d1bc8cdd307",
|
||||
"size_bytes": 64495784
|
||||
"new_version": "1.8.17-alpha",
|
||||
"sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
|
||||
"size_bytes": 64953344
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.19-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.19-alpha/archipelago-frontend-1.8.19-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.19-alpha.tar.gz",
|
||||
"new_version": "1.8.19-alpha",
|
||||
"sha256": "fbbaa237e2ea4e9e576dda9b15fdcf3c59c40bc55bfee4ebbea303b0172fc49b",
|
||||
"size_bytes": 97142031
|
||||
"current_version": "1.8.17-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago-frontend-1.8.17-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.17-alpha.tar.gz",
|
||||
"new_version": "1.8.17-alpha",
|
||||
"sha256": "faf692e9a0e16268357bcac2bf86b62950ae49663e3c95982e54a132bb761980",
|
||||
"size_bytes": 98801608
|
||||
}
|
||||
],
|
||||
"release_date": "2026-09-28",
|
||||
"signature": "4da9bf766d94c2de6641619a36663106791c7a1d342b729c666662cdde1feabdf11c51994c1cbbe2a0e2b270c316d77763435e976ce22730855b3822bc9d390a",
|
||||
"release_date": "2026-09-15",
|
||||
"signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.19-alpha"
|
||||
"version": "1.8.17-alpha"
|
||||
}
|
||||
|
||||
+18
-17
@@ -1,29 +1,30 @@
|
||||
{
|
||||
"changelog": [
|
||||
"Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.",
|
||||
"Embedded AIUI now stays transparent so the dashboard background appears once.",
|
||||
"AIUI background fixes are now included reliably in OTA updates and fresh installations."
|
||||
"Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.",
|
||||
"Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.",
|
||||
"Large payment backlogs are fetched from newest to oldest with a saved cursor, so polling can resume after interruptions or page limits. Payments sharing the same timestamp remain reachable.",
|
||||
"Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.19-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.19-alpha/archipelago",
|
||||
"current_version": "1.8.17-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.19-alpha",
|
||||
"sha256": "bb500d02567ad16179179d43138cc6fdafee680835262026eeaa7d1bc8cdd307",
|
||||
"size_bytes": 64495784
|
||||
"new_version": "1.8.17-alpha",
|
||||
"sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
|
||||
"size_bytes": 64953344
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.19-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.19-alpha/archipelago-frontend-1.8.19-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.19-alpha.tar.gz",
|
||||
"new_version": "1.8.19-alpha",
|
||||
"sha256": "fbbaa237e2ea4e9e576dda9b15fdcf3c59c40bc55bfee4ebbea303b0172fc49b",
|
||||
"size_bytes": 97142031
|
||||
"current_version": "1.8.17-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago-frontend-1.8.17-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.17-alpha.tar.gz",
|
||||
"new_version": "1.8.17-alpha",
|
||||
"sha256": "faf692e9a0e16268357bcac2bf86b62950ae49663e3c95982e54a132bb761980",
|
||||
"size_bytes": 98801608
|
||||
}
|
||||
],
|
||||
"release_date": "2026-09-28",
|
||||
"signature": "4da9bf766d94c2de6641619a36663106791c7a1d342b729c666662cdde1feabdf11c51994c1cbbe2a0e2b270c316d77763435e976ce22730855b3822bc9d390a",
|
||||
"release_date": "2026-09-15",
|
||||
"signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.19-alpha"
|
||||
"version": "1.8.17-alpha"
|
||||
}
|
||||
|
||||
@@ -78,17 +78,15 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
|
||||
STAGING_DIR=$(mktemp -d -t archipelago-frontend.XXXXXX)
|
||||
echo "Staging frontend archive in $STAGING_DIR..."
|
||||
cp -r "$FRONTEND_DIST/." "$STAGING_DIR/"
|
||||
# create-release.sh folds the freshly built AIUI into FRONTEND_DIST.
|
||||
# Never overlay it with the older demo bundle (or nest aiui/aiui/).
|
||||
if [ ! -f "$STAGING_DIR/aiui/index.html" ] || \
|
||||
[ ! -f "$STAGING_DIR/aiui/BUILD-INFO" ]; then
|
||||
echo "Error: fresh AIUI payload missing from frontend dist" >&2
|
||||
exit 1
|
||||
# Bake AIUI in so fresh installs pick it up. OTA already
|
||||
# carries-forward the existing aiui/ if the tarball lacks one
|
||||
# (update.rs:922), but including it here makes the tarball
|
||||
# the single source of truth instead of relying on a side-
|
||||
# effect of the in-place swap.
|
||||
if [ -d "$PROJECT_ROOT/demo/aiui" ] && [ -f "$PROJECT_ROOT/demo/aiui/index.html" ]; then
|
||||
echo " Including AIUI from demo/aiui/"
|
||||
cp -r "$PROJECT_ROOT/demo/aiui" "$STAGING_DIR/aiui"
|
||||
fi
|
||||
grep -Fxq "commit=$(git -C "$PROJECT_ROOT" rev-parse HEAD)" "$STAGING_DIR/aiui/BUILD-INFO" || {
|
||||
echo "Error: AIUI payload was not built from the current commit" >&2
|
||||
exit 1
|
||||
}
|
||||
# OTA bridge for nodes running older updaters: they only know how to
|
||||
# apply the backend binary and frontend archive. Carry host runtime
|
||||
# assets inside the frontend tarball; the new backend promotes them
|
||||
|
||||
@@ -169,11 +169,15 @@ else
|
||||
fi
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Build AIUI from the same source as the release. The checked-in demo bundle
|
||||
# can predate source fixes and must never overwrite the production payload.
|
||||
bash "$SCRIPT_DIR/build-aiui.sh"
|
||||
rm -rf "$PROJECT_ROOT/web/dist/neode-ui/aiui"
|
||||
cp -r "$PROJECT_ROOT/aiui/packages/app/dist" "$PROJECT_ROOT/web/dist/neode-ui/aiui"
|
||||
# npm run build wipes web/dist — fold AIUI straight back in. The OTA tarball
|
||||
# bakes it from demo/aiui independently, but build-iso-release.sh's
|
||||
# verify-artifacts guard checks web/dist/neode-ui/aiui and failed on two
|
||||
# consecutive releases (.127, .129) because this fold-in was manual.
|
||||
if [ -d "$PROJECT_ROOT/demo/aiui" ] && [ -f "$PROJECT_ROOT/demo/aiui/index.html" ]; then
|
||||
rm -rf "$PROJECT_ROOT/web/dist/neode-ui/aiui"
|
||||
cp -r "$PROJECT_ROOT/demo/aiui" "$PROJECT_ROOT/web/dist/neode-ui/aiui"
|
||||
echo " AIUI folded into web/dist from demo/aiui"
|
||||
fi
|
||||
|
||||
# npm run build can silently no-op (vue-tsc EACCES burned us before) — a stale
|
||||
# dist would ship with a perfectly valid sha256. Require the freshly built
|
||||
|
||||
Reference in New Issue
Block a user