Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb0193932f | ||
|
|
63cb68451a | ||
|
|
17cfebbe26 | ||
|
|
379fb930fc | ||
|
|
1bebdeac0f |
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
## Unreleased
|
## 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)
|
## 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.
|
- 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.
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
<div
|
<div
|
||||||
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
|
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
|
||||||
:class="[]"
|
:class="[]"
|
||||||
:style="isDark
|
:style="isEmbedded
|
||||||
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
|
? { background: 'transparent' }
|
||||||
: isEmbedded
|
: isDark
|
||||||
? { background: 'transparent' }
|
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
|
||||||
: { backgroundColor: '#f5f4f1' }"
|
: { backgroundColor: '#f5f4f1' }"
|
||||||
>
|
>
|
||||||
<div v-if="isDark" class="absolute inset-0 pointer-events-none bg-black/20" />
|
<div v-if="isDark && !isEmbedded" class="absolute inset-0 pointer-events-none bg-black/20" />
|
||||||
|
|
||||||
<!-- Desktop layout -->
|
<!-- Desktop layout -->
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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
|
||||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.17-alpha"
|
version = "1.8.18-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.17-alpha"
|
version = "1.8.18-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||||
|
|||||||
@@ -162,25 +162,11 @@ 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."}"#,
|
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) => Ok(build_response(
|
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
"text/plain",
|
"text/plain",
|
||||||
hyper::Body::from("Content not found"),
|
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,11 +22,9 @@ 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
|
/// 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
|
/// 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
|
/// doesn't lose the value. For Fedimint the spender can reissue its own
|
||||||
/// un-redeemed notes; for Cashu the proofs are received back. Returns whether
|
/// un-redeemed notes; for Cashu the proofs are received back. Fails silently if
|
||||||
/// the value came back: false if the seller already claimed the token (then
|
/// the seller already claimed the token (then the value is genuinely gone).
|
||||||
/// the value is genuinely gone), so callers never tell the buyer they were
|
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) {
|
||||||
/// refunded when they weren't.
|
|
||||||
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) -> bool {
|
|
||||||
let res = match backend {
|
let res = match backend {
|
||||||
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
||||||
.await
|
.await
|
||||||
@@ -34,29 +32,13 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &
|
|||||||
_ => ecash::receive_token(data_dir, token).await,
|
_ => ecash::receive_token(data_dir, token).await,
|
||||||
};
|
};
|
||||||
match res {
|
match res {
|
||||||
Ok(sats) => {
|
Ok(sats) => tracing::info!(
|
||||||
tracing::info!(
|
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
||||||
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
),
|
||||||
);
|
Err(e) => tracing::warn!(
|
||||||
true
|
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
||||||
}
|
claimed it): {e:#}"
|
||||||
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."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,13 +564,9 @@ impl RpcHandler {
|
|||||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||||
// The token was already minted/spent — reclaim it so the buyer
|
// The token was already minted/spent — reclaim it so the buyer
|
||||||
// doesn't lose the value when the seller was simply unreachable.
|
// doesn't lose the value when the seller was simply unreachable.
|
||||||
let reclaimed =
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"error": format!(
|
"error": "Could not reach the peer over mesh or Tor — it may be offline. Your ecash was refunded to your wallet. Please try again."
|
||||||
"Could not reach the peer over mesh or Tor — it may be offline. {} Please try again.",
|
|
||||||
refund_note(reclaimed)
|
|
||||||
)
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -614,19 +592,15 @@ impl RpcHandler {
|
|||||||
);
|
);
|
||||||
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
||||||
// their funds (the spent-but-unredeemed-notes case the user hit).
|
// their funds (the spent-but-unredeemed-notes case the user hit).
|
||||||
let reclaimed =
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
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 {
|
let hint = match used_backend {
|
||||||
"fedimint" => "the seller may not be in the same Fedimint federation as you",
|
"fedimint" => "the seller isn't in the same Fedimint federation as you",
|
||||||
_ => "the seller may not accept your Cashu mint",
|
_ => "the seller doesn't accept your Cashu mint",
|
||||||
};
|
};
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"error": format!(
|
"error": format!(
|
||||||
"Payment not accepted by the seller — {hint}. {} Try the other ecash \
|
"Payment rejected by the seller — {hint}. Your ecash was refunded to \
|
||||||
type, or use a shared mint/federation.",
|
your wallet. Try the other ecash type, or use a shared mint/federation."
|
||||||
refund_note(reclaimed)
|
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -635,10 +609,9 @@ impl RpcHandler {
|
|||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
||||||
let reclaimed =
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"error": format!("Peer returned an error ({status}). {}", refund_note(reclaimed))
|
"error": format!("Peer returned an error ({status}). Your ecash was refunded to your wallet.")
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,110 +5,13 @@
|
|||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::future::Future;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, LazyLock};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
const CATALOG_FILE: &str = "content/catalog.json";
|
const CATALOG_FILE: &str = "content/catalog.json";
|
||||||
const CONTENT_DIR: &str = "content/files";
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ContentItem {
|
pub struct ContentItem {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -393,31 +296,6 @@ 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
|
// Check access control
|
||||||
if !owner_session {
|
if !owner_session {
|
||||||
match &item.access {
|
match &item.access {
|
||||||
@@ -431,10 +309,7 @@ pub async fn serve_content(
|
|||||||
if let Some(token) = payment_token {
|
if let Some(token) = payment_token {
|
||||||
if (method_accepted(&item.access, "ecash")
|
if (method_accepted(&item.access, "ecash")
|
||||||
|| method_accepted(&item.access, "fedimint"))
|
|| method_accepted(&item.access, "fedimint"))
|
||||||
&& authorize_payment(token, id, || {
|
&& verify_payment_token(data_dir, token, *price_sats).await
|
||||||
verify_payment_token(data_dir, token, *price_sats)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
authorized = true;
|
authorized = true;
|
||||||
}
|
}
|
||||||
@@ -461,6 +336,24 @@ 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)
|
let metadata = fs::metadata(&file_path)
|
||||||
.await
|
.await
|
||||||
.context("Failed to read file metadata")?;
|
.context("Failed to read file metadata")?;
|
||||||
@@ -832,182 +725,3 @@ mod prune_missing_content_tests {
|
|||||||
assert_eq!(reloaded.items[0].id, "present-item");
|
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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 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
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/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.")
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/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
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.8.17-alpha",
|
"version": "1.8.18-alpha",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.8.17-alpha",
|
"version": "1.8.18-alpha",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scure/bip39": "^2.2.0",
|
"@scure/bip39": "^2.2.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.8.17-alpha",
|
"version": "1.8.18-alpha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "./start-dev.sh",
|
"start": "./start-dev.sh",
|
||||||
|
|||||||
@@ -362,6 +362,18 @@ init()
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
<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 -->
|
<!-- v1.8.17-alpha -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
|||||||
+17
-18
@@ -1,30 +1,29 @@
|
|||||||
{
|
{
|
||||||
"changelog": [
|
"changelog": [
|
||||||
"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.",
|
"Framework startup prioritizes Bitcoin and LND before unrelated containers, and unavailable LND balances remain unavailable instead of appearing as false zeroes.",
|
||||||
"Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.",
|
"Cashu Receive guides unseeded wallets through recovery-phrase setup, with shorter backup guidance and a single-column layout.",
|
||||||
"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 live Framework verification for automatic LND unlock, native balance preservation, Cashu address registration, and proof preservation."
|
||||||
"Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs."
|
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"current_version": "1.8.17-alpha",
|
"current_version": "1.8.18-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago",
|
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.18-alpha/archipelago",
|
||||||
"name": "archipelago",
|
"name": "archipelago",
|
||||||
"new_version": "1.8.17-alpha",
|
"new_version": "1.8.18-alpha",
|
||||||
"sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
|
"sha256": "c76415c295dd42159e45e74d93f7c2f6fa39b0351946a8303f8cfba1dd3a6087",
|
||||||
"size_bytes": 64953344
|
"size_bytes": 64497240
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"current_version": "1.8.17-alpha",
|
"current_version": "1.8.18-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago-frontend-1.8.17-alpha.tar.gz",
|
"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.17-alpha.tar.gz",
|
"name": "archipelago-frontend-1.8.18-alpha.tar.gz",
|
||||||
"new_version": "1.8.17-alpha",
|
"new_version": "1.8.18-alpha",
|
||||||
"sha256": "faf692e9a0e16268357bcac2bf86b62950ae49663e3c95982e54a132bb761980",
|
"sha256": "57da88b1a8f3d2841867994a95e9e4e728c19616abcde8e3d3f151c6d94ed501",
|
||||||
"size_bytes": 98801608
|
"size_bytes": 98801020
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"release_date": "2026-09-15",
|
"release_date": "2026-09-20",
|
||||||
"signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
|
"signature": "84e0e1a20750f0d4ff27ba539c2e9424a5b02c19a5689f73cdf82813212acae19e0af244a62e66ac37a35d97d80deb2833819d809b91a7822f2bfd785d3fe30d",
|
||||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||||
"version": "1.8.17-alpha"
|
"version": "1.8.18-alpha"
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-18
@@ -1,30 +1,29 @@
|
|||||||
{
|
{
|
||||||
"changelog": [
|
"changelog": [
|
||||||
"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.",
|
"Framework startup prioritizes Bitcoin and LND before unrelated containers, and unavailable LND balances remain unavailable instead of appearing as false zeroes.",
|
||||||
"Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.",
|
"Cashu Receive guides unseeded wallets through recovery-phrase setup, with shorter backup guidance and a single-column layout.",
|
||||||
"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 live Framework verification for automatic LND unlock, native balance preservation, Cashu address registration, and proof preservation."
|
||||||
"Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs."
|
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"current_version": "1.8.17-alpha",
|
"current_version": "1.8.18-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago",
|
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.18-alpha/archipelago",
|
||||||
"name": "archipelago",
|
"name": "archipelago",
|
||||||
"new_version": "1.8.17-alpha",
|
"new_version": "1.8.18-alpha",
|
||||||
"sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
|
"sha256": "c76415c295dd42159e45e74d93f7c2f6fa39b0351946a8303f8cfba1dd3a6087",
|
||||||
"size_bytes": 64953344
|
"size_bytes": 64497240
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"current_version": "1.8.17-alpha",
|
"current_version": "1.8.18-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago-frontend-1.8.17-alpha.tar.gz",
|
"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.17-alpha.tar.gz",
|
"name": "archipelago-frontend-1.8.18-alpha.tar.gz",
|
||||||
"new_version": "1.8.17-alpha",
|
"new_version": "1.8.18-alpha",
|
||||||
"sha256": "faf692e9a0e16268357bcac2bf86b62950ae49663e3c95982e54a132bb761980",
|
"sha256": "57da88b1a8f3d2841867994a95e9e4e728c19616abcde8e3d3f151c6d94ed501",
|
||||||
"size_bytes": 98801608
|
"size_bytes": 98801020
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"release_date": "2026-09-15",
|
"release_date": "2026-09-20",
|
||||||
"signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
|
"signature": "84e0e1a20750f0d4ff27ba539c2e9424a5b02c19a5689f73cdf82813212acae19e0af244a62e66ac37a35d97d80deb2833819d809b91a7822f2bfd785d3fe30d",
|
||||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||||
"version": "1.8.17-alpha"
|
"version": "1.8.18-alpha"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user