Compare commits

..
Author SHA1 Message Date
ssmithxandClaude Sonnet 5 19e01cd5de fix(content): never take a paid buyer's ecash and then fail to deliver
2026-09-18: a peer purchase paid 10 sats, the seller redeemed them, and the
buyer got no file plus a "seller doesn't accept your Cashu mint" error.
Three defects lined up:

1. The seller checked file existence with stat() but only read the file
   AFTER redeeming the payment. Filebrowser-owned 0640 files (uid 100999)
   passed stat but failed fs::read for the archipelago service user.
   serve_content now checks existence and readability BEFORE the payment
   gate, so an unservable file costs the buyer nothing.
2. The HTTP handler mapped every serve_content error to a bare, unlogged
   404. A server-side failure is now a logged 500. (A 404 also makes the
   buyer's Auto transport re-send the request over Tor.)
3. That re-send carried the same single-use token, which the mint had
   already spent, so the seller answered 402. Redemption is now
   idempotent: a token that verified for an item keeps authorising that
   item for 10 minutes (per token, per item; SHA-256 keyed, in-memory,
   concurrent requests serialised, failures never cached).

Buyer side: reclaim_spent_ecash now reports whether the refund worked, and
the error text no longer claims "refunded" when it wasn't, or asserts the
seller rejects the mint when the cause is unknown.

Adds tests for replay, concurrency, failure-not-cached, cross-item, and
unreadable-file-before-payment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 16:28:19 +00:00
17 changed files with 412 additions and 406 deletions
-6
View File
@@ -2,12 +2,6 @@
## Unreleased
## 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.
+5 -5
View File
@@ -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
-102
View File
@@ -1,102 +0,0 @@
app:
id: conduit-market
name: Conduit Market
version: 1.0.0
# Built by this project from a pinned upstream commit (Conduit ships no
# Dockerfile of its own) — there is no upstream release feed/tag to watch,
# so re-pin CONDUIT_COMMIT in the Dockerfile deliberately, by hand.
upstream:
kind: github
repo: Conduit-BTC/conduit-mono
description: |
Decentralized Nostr + Lightning marketplace (apps/market from
Conduit-BTC/conduit-mono). Pure client-side SPA — listings are NIP-99
events, orders are encrypted DMs, payments settle over Lightning via
NWC — confirmed by reading packages/core/src/config.ts and
apps/market/package.json directly; there is no custom backend to run.
Public/private is a genuine runtime toggle, not two separate builds:
Vite bakes VITE_* relay env vars into the bundle at compile time (see
the comment on getViteEnv() in config.ts), so a single image built once
could never be reconfigured afterward via ordinary env vars. This
package patches config.ts (docker/conduit-market/apply-runtime-override.py)
to merge in a window.__CONDUIT_RUNTIME_ENV__ override object, written by
docker-entrypoint.sh from CONDUIT_MODE/CONDUIT_PRIVATE_RELAY_URLS at
container *start*. Flipping CONDUIT_MODE and restarting the container is
enough — no rebuild required. CONDUIT_MODE=private with no relay URL set
refuses to start rather than silently falling back to the public network.
category: money
container:
build:
context: /opt/archipelago/docker/conduit-market
dockerfile: Dockerfile
tag: localhost/conduit-market:local
network: archy-net
dependencies: []
resources:
memory_limit: 64Mi
security:
capabilities: []
# false, not true: docker-entrypoint.sh regenerates runtime-env.js under
# /usr/share/nginx/html (part of the image root fs, not a volume) on
# every container start — that's the actual public/private switch, so
# the root fs must stay writable for the mode flip to take effect.
readonly_root: false
no_new_privileges: true
network_policy: bridge
ports:
- host: 8091
container: 8080
protocol: tcp
bind: 127.0.0.1
auth: none
auth_rationale: >-
Public storefront meant for anonymous shoppers, not an admin
surface — Conduit has no login of its own (identity is a Nostr
keypair held client-side) and a cookie/session gate in front of it
would just block real customers from browsing or checking out.
volumes: []
# CONDUIT_MODE (public|private) and CONDUIT_PRIVATE_RELAY_URLS are the
# actual public/private switch, read by docker-entrypoint.sh at container
# start — see the description above. Defaults here are the safe/inert
# choice (public, using Conduit's own canonical relay network); an
# operator who wants an isolated single-relay storefront sets both.
environment:
- CONDUIT_MODE=public
- CONDUIT_PRIVATE_RELAY_URLS=
- CONDUIT_LIGHTNING_NETWORK=mainnet
health_check:
type: http
endpoint: http://127.0.0.1:8091
path: /health
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Storefront
description: Conduit Market storefront
type: ui
port: 8091
protocol: http
path: /
metadata:
category: money
tier: optional
author: Conduit-BTC
repo: https://github.com/Conduit-BTC/conduit-mono
features:
- Nostr-native marketplace listings (NIP-99)
- Encrypted order flow over Nostr DMs
- Lightning checkout via Nostr Wallet Connect
- Runtime-configurable public or private relay mode, no rebuild
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.18-alpha"
version = "1.8.17-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.18-alpha"
version = "1.8.17-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
+15 -1
View File
@@ -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."}"#,
),
))
}
}
}
+46 -19
View File
@@ -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))
}));
}
+305 -19
View File
@@ -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)));
}
}
-62
View File
@@ -1,62 +0,0 @@
# syntax=docker/dockerfile:1.6
#
# Packages Conduit Market (github.com/Conduit-BTC/conduit-mono, apps/market)
# as a static Archipelago app. Conduit itself is a pure client-side SPA --
# products are Nostr events (NIP-99), orders are encrypted DMs, payments go
# over Lightning via NWC -- no custom backend, confirmed by reading
# packages/core/src/config.ts and apps/market/package.json directly rather
# than assumed. Upstream ships no Dockerfile; this one is new.
#
# Pinned to a specific upstream commit (not a moving branch) for
# reproducibility, matching the archy convention already used for
# apps/cuprate. Re-pin deliberately, not on every rebuild.
#
# oven/bun:1.1-slim is pinned to an EOL Debian bullseye base whose
# -security pool no longer serves the package versions it references
# (404s on ca-certificates/curl et al). oven/bun:1-slim currently resolves
# to bun 1.4.x on Debian trixie (current), so use that instead -- still a
# floating minor tag, but the alternative (hand-pinning a bullseye/bookworm
# digest) just reintroduces the same staleness problem later.
FROM oven/bun:1-slim AS build
ARG CONDUIT_COMMIT=c6606382dda8953763646f4498b2c4a4103da0a3
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl python3 \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL "https://github.com/Conduit-BTC/conduit-mono/archive/${CONDUIT_COMMIT}.tar.gz" \
-o /tmp/conduit.tar.gz \
&& tar xzf /tmp/conduit.tar.gz --strip-components=1 \
&& rm /tmp/conduit.tar.gz
# Full workspace install: apps/market depends on @conduit/core and
# @conduit/ui via workspace:*, so bun needs the whole monorepo present to
# link them, even though only the market app actually gets built below.
RUN bun install --frozen-lockfile
COPY apply-runtime-override.py /build/apply-runtime-override.py
RUN python3 apply-runtime-override.py
# Loads window.__CONDUIT_RUNTIME_ENV__ (written by docker-entrypoint.sh at
# container start) before the app bundle runs. A plain (non-module) script
# tag with an absolute path: Vite copies these through to dist/index.html
# verbatim without trying to resolve them as part of the module graph, and
# does not require the file to exist in the source tree at build time.
RUN sed -i 's#<script type="module" src="/src/main.tsx"></script>#<script src="/runtime-env.js"></script>\n <script type="module" src="/src/main.tsx"></script>#' \
apps/market/index.html
# Leave every VITE_* relay/network var unset here on purpose -- the runtime
# override (see above) is the actual public/private switch; baking any of
# these in at build time would defeat the point of a live-configurable
# single image.
RUN bun run --filter '@conduit/market' build
FROM nginx:1.27-alpine
COPY --from=build /build/apps/market/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.sh /docker-entrypoint.d/50-conduit-runtime-env.sh
RUN chmod +x /docker-entrypoint.d/50-conduit-runtime-env.sh
EXPOSE 8080
@@ -1,70 +0,0 @@
#!/usr/bin/env python3
"""Patches packages/core/src/config.ts so it can be reconfigured
public/private at container-start time instead of only at build time.
Vite bakes VITE_* env vars into the bundle as literal strings when it
compiles (import.meta.env.VITE_FOO is a static replacement, not a runtime
lookup) -- confirmed directly in config.ts's own comment on getViteEnv().
That means a container image built once can never be flipped between an
isolated private relay and the public Conduit network via ordinary env
vars after the fact; the values are frozen into the compiled JS.
This packaging needs exactly that live toggle, so docker-entrypoint.sh
writes a small /runtime-env.js at *container start* (from real env vars)
setting window.__CONDUIT_RUNTIME_ENV__ before the app bundle runs. This
script inserts one small override function right before the single call
site that consumes getViteEnv()'s result (`const env = getViteEnv()`,
confirmed to be the only call site in the file and to run once at module
load), so only that one merge point needs to change. Only non-empty
string fields override the Vite-baked default; a build with no runtime
override behaves identically to stock upstream Conduit.
Uses a literal string anchor rather than a line-numbered diff/patch --
more robust against upstream reformatting a comment or reflowing
unrelated lines than a traditional unified diff would be.
"""
import sys
CONFIG_PATH = "packages/core/src/config.ts"
ANCHOR = "const env = getViteEnv()"
OVERRIDE_BLOCK = '''// --- Archipelago runtime relay override -------------------------------------
// See docker/conduit-market/apply-runtime-override.py in the archy repo for
// why this exists: Vite bakes VITE_* env vars into the bundle at build
// time, so a container image built once can never be reconfigured
// public/private afterward via ordinary env vars. docker-entrypoint.sh
// writes window.__CONDUIT_RUNTIME_ENV__ from real environment variables at
// container start, before this bundle runs.
function getRuntimeOverrides(): Partial<ReturnType<typeof getViteEnv>> {
if (typeof window === "undefined") return {}
const raw = (window as unknown as Record<string, unknown>)
.__CONDUIT_RUNTIME_ENV__
if (!raw || typeof raw !== "object") return {}
const out: Record<string, string> = {}
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (typeof value === "string" && value.length > 0) out[key] = value
}
return out as Partial<ReturnType<typeof getViteEnv>>
}
const env = { ...getViteEnv(), ...getRuntimeOverrides() }'''
with open(CONFIG_PATH) as f:
content = f.read()
count = content.count(ANCHOR)
if count != 1:
print(
f"FATAL: expected exactly 1 occurrence of anchor line in {CONFIG_PATH}, "
f"found {count}. Upstream config.ts has likely changed in a way that "
"needs this script re-checked by hand before it can safely patch it.",
file=sys.stderr,
)
sys.exit(1)
content = content.replace(ANCHOR, OVERRIDE_BLOCK, 1)
with open(CONFIG_PATH, "w") as f:
f.write(content)
print(f"Patched {CONFIG_PATH}: runtime relay override installed.")
@@ -1,43 +0,0 @@
#!/bin/sh
# Runs via nginx's own /docker-entrypoint.d/ hook mechanism (official nginx
# image sources every executable script there before starting nginx) --
# no custom ENTRYPOINT needed. Writes /runtime-env.js, loaded by index.html
# before the app bundle (see Dockerfile), so this container's own env vars
# can flip Conduit between an isolated private relay and the public Conduit
# network without rebuilding the image -- see
# packages/core/src/config.ts's getRuntimeOverrides() (installed by
# apply-runtime-override.py) for the other half of this.
set -eu
CONDUIT_MODE="${CONDUIT_MODE:-public}"
OUT=/usr/share/nginx/html/runtime-env.js
if [ "$CONDUIT_MODE" = "private" ]; then
if [ -z "${CONDUIT_PRIVATE_RELAY_URLS:-}" ]; then
echo "conduit-market: CONDUIT_MODE=private requires CONDUIT_PRIVATE_RELAY_URLS (comma-separated wss:// URLs) -- refusing to start with no relay configured" >&2
exit 1
fi
cat > "$OUT" <<EOF
window.__CONDUIT_RUNTIME_ENV__ = {
relayUrl: "",
defaultRelayUrl: "${CONDUIT_PRIVATE_RELAY_URLS}",
defaultRelays: "${CONDUIT_PRIVATE_RELAY_URLS}",
appWriteRelayUrls: "${CONDUIT_PRIVATE_RELAY_URLS}",
publicRelayUrls: "${CONDUIT_PRIVATE_RELAY_URLS}",
commerceRelayUrls: "${CONDUIT_PRIVATE_RELAY_URLS}",
lightningNetwork: "${CONDUIT_LIGHTNING_NETWORK:-mainnet}"
};
EOF
echo "conduit-market: CONDUIT_MODE=private -- isolated to ${CONDUIT_PRIVATE_RELAY_URLS}"
else
# public: no relay overrides at all, so Conduit's own built-in canonical
# public relay defaults (packages/core/src/config.ts,
# CANONICAL_APP_RELAY_DEFINITIONS) apply exactly as they do upstream --
# this store fully participates in the wider Conduit network.
cat > "$OUT" <<EOF
window.__CONDUIT_RUNTIME_ENV__ = {
lightningNetwork: "${CONDUIT_LIGHTNING_NETWORK:-mainnet}"
};
EOF
echo "conduit-market: CONDUIT_MODE=public -- using Conduit's default public relay network"
fi
-28
View File
@@ -1,28 +0,0 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Regenerated by docker-entrypoint.d/50-conduit-runtime-env.sh on every
# container start -- must never be cached, or a mode flip (public <->
# private) followed by a container restart would silently keep serving
# the old relay config to anyone with a warm cache.
location = /runtime-env.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
}
# Client-side routing (@tanstack/react-router) -- unknown paths fall
# through to index.html rather than 404ing.
location / {
try_files $uri $uri/ /index.html;
}
location = /health {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.8.18-alpha",
"version": "1.8.17-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.8.18-alpha",
"version": "1.8.17-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.8.18-alpha",
"version": "1.8.17-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
@@ -362,18 +362,6 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- 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 now brings Bitcoin and LND up before unrelated containers, and the dashboard keeps balances unavailable instead of showing a false zero when LND is not ready.</p>
<p>Cashu wallets can set up a recovery phrase from Receive, with clearer backup and restore guidance.</p>
<p>Ecash backup guidance is shorter and arranged in a single column for easier use on small screens.</p>
</div>
</div>
<!-- v1.8.17-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
+18 -17
View File
@@ -1,29 +1,30 @@
{
"changelog": [
"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."
"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.18-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.18-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.18-alpha",
"sha256": "c76415c295dd42159e45e74d93f7c2f6fa39b0351946a8303f8cfba1dd3a6087",
"size_bytes": 64497240
"new_version": "1.8.17-alpha",
"sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
"size_bytes": 64953344
},
{
"current_version": "1.8.18-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.18-alpha/archipelago-frontend-1.8.18-alpha.tar.gz",
"name": "archipelago-frontend-1.8.18-alpha.tar.gz",
"new_version": "1.8.18-alpha",
"sha256": "57da88b1a8f3d2841867994a95e9e4e728c19616abcde8e3d3f151c6d94ed501",
"size_bytes": 98801020
"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-20",
"signature": "84e0e1a20750f0d4ff27ba539c2e9424a5b02c19a5689f73cdf82813212acae19e0af244a62e66ac37a35d97d80deb2833819d809b91a7822f2bfd785d3fe30d",
"release_date": "2026-09-15",
"signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.18-alpha"
"version": "1.8.17-alpha"
}
+18 -17
View File
@@ -1,29 +1,30 @@
{
"changelog": [
"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."
"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.18-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.18-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.18-alpha",
"sha256": "c76415c295dd42159e45e74d93f7c2f6fa39b0351946a8303f8cfba1dd3a6087",
"size_bytes": 64497240
"new_version": "1.8.17-alpha",
"sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
"size_bytes": 64953344
},
{
"current_version": "1.8.18-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.18-alpha/archipelago-frontend-1.8.18-alpha.tar.gz",
"name": "archipelago-frontend-1.8.18-alpha.tar.gz",
"new_version": "1.8.18-alpha",
"sha256": "57da88b1a8f3d2841867994a95e9e4e728c19616abcde8e3d3f151c6d94ed501",
"size_bytes": 98801020
"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-20",
"signature": "84e0e1a20750f0d4ff27ba539c2e9424a5b02c19a5689f73cdf82813212acae19e0af244a62e66ac37a35d97d80deb2833819d809b91a7822f2bfd785d3fe30d",
"release_date": "2026-09-15",
"signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.18-alpha"
"version": "1.8.17-alpha"
}