use std::sync::Arc;
use actix_web::{web, Error as ActixError, HttpRequest, HttpResponse};
use bytes::Bytes;
use solid_pod_rs::provenance::{
prov_ttl, AnchorPolicy, BlockAnchorer, ClosedEpoch, EpochAccumulator, ProvenanceMark,
};
use solid_pod_rs::storage::Storage;
use solid_pod_rs::wac::{anchor_mode_of, AnchorMode};
use crate::mempool::{MempoolBlockAnchorer, MempoolHttpClient};
use crate::trail_store::load_trail;
use crate::{extract_pubkey, pod_repo_path, require_pod_owner, AppState};
const DEFAULT_EPOCH_SIZE: usize = 16;
pub const EPOCH_SIZE_ENV: &str = "JSS_PROV_EPOCH_SIZE";
const EPOCH_PATH: &str = "/.well-known/prov/epoch.json";
fn epoch_size() -> usize {
std::env::var(EPOCH_SIZE_ENV)
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.filter(|n| *n >= 1)
.unwrap_or(DEFAULT_EPOCH_SIZE)
}
pub(crate) async fn resolve_anchor_policy(
state: &AppState,
resource_path: &str,
) -> (AnchorPolicy, Option<String>) {
let acl = match crate::find_effective_acl_dyn(&*state.storage, resource_path).await {
Ok(Some(doc)) => doc,
_ => return (AnchorPolicy::Never, None),
};
let Some(graph) = acl.graph.as_ref() else {
return (AnchorPolicy::Never, None);
};
for auth in graph {
if let Some(conds) = auth.condition.as_ref() {
if let Some(mode) = anchor_mode_of(conds) {
let ticker = conds.iter().find_map(|c| match c {
solid_pod_rs::wac::Condition::ProvenanceAnchor(b) => b.ticker.clone(),
_ => None,
});
let policy = match mode {
AnchorMode::Inline => AnchorPolicy::HighValue,
AnchorMode::Epoch => AnchorPolicy::Epoch,
};
return (policy, ticker);
}
}
}
(AnchorPolicy::Never, None)
}
pub(crate) async fn build_anchorer(
state: &AppState,
ticker_override: Option<&str>,
) -> Option<(Arc<dyn BlockAnchorer>, String, String)> {
let token = state.pay_config.token.as_ref()?;
let ticker = ticker_override
.filter(|t| !t.is_empty())
.map(str::to_string)
.unwrap_or_else(|| token.ticker.clone());
if ticker.is_empty() {
return None;
}
let trail = load_trail(&state.storage, &ticker).await.ok().flatten()?;
let network = trail.network.clone();
let mempool = match &state.mempool_url {
Some(url) => MempoolHttpClient::new(url.clone()),
None => MempoolHttpClient::from_env(),
};
let anchorer = MempoolBlockAnchorer::with_storage(mempool, state.storage.clone());
Some((Arc::new(anchorer), ticker, network))
}
async fn load_epoch_commits(storage: &Arc<dyn Storage>) -> Vec<String> {
match storage.get(EPOCH_PATH).await {
Ok((bytes, _)) => serde_json::from_slice(&bytes).unwrap_or_default(),
Err(_) => Vec::new(),
}
}
async fn save_epoch_commits(storage: &Arc<dyn Storage>, commits: &[String]) -> Result<(), String> {
let body = serde_json::to_vec(commits).map_err(|e| e.to_string())?;
storage
.put(EPOCH_PATH, Bytes::from(body), "application/json")
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
pub(crate) async fn epoch_push_and_maybe_anchor(
state: &AppState,
anchorer: &Arc<dyn BlockAnchorer>,
ticker: &str,
network: &str,
commit_sha: &str,
) -> Result<Option<ClosedEpoch>, String> {
let size = epoch_size();
let mut commits = load_epoch_commits(&state.storage).await;
if !commits.iter().any(|c| c == commit_sha) {
commits.push(commit_sha.to_string());
}
if commits.len() < size {
save_epoch_commits(&state.storage, &commits).await?;
return Ok(None);
}
let mut acc = EpochAccumulator::new(size);
for c in &commits {
acc.push(c.clone());
}
let closed = acc.close().ok_or("epoch close produced nothing")?;
anchorer
.anchor(ticker, &closed.root, network)
.await
.map_err(|e| e.to_string())?;
save_epoch_commits(&state.storage, &[]).await?;
Ok(Some(closed))
}
async fn handle_resolve(
path: web::Path<(String, String)>,
state: web::Data<AppState>,
) -> Result<HttpResponse, ActixError> {
let (pod, commit_sha) = path.into_inner();
let Some(repo) = pod_repo_path(&state, &pod) else {
return Ok(prov_err("git provenance not available (no FS backend / invalid pod)", 501));
};
if !repo.join(".git").is_dir() {
return Ok(prov_err("pod is not git-backed", 404));
}
let resolved = match solid_pod_rs_git::resolve_commit(&repo, &commit_sha).await {
Ok(r) => r,
Err(e) => {
let code = e.status_code();
return Ok(prov_err(&format!("commit not found: {e}"), if code == 404 { 404 } else { 400 }));
}
};
let resource_rel = resolved
.files
.iter()
.find(|f| !is_sidecar(f))
.cloned();
let Some(resource_rel) = resource_rel else {
return Ok(prov_err("commit touched no content resource", 404));
};
let resource = format!("/{pod}/{resource_rel}");
let sidecar = format!("{resource}.prov.ttl");
let sidecar_ttl = state
.storage
.get(&sidecar)
.await
.ok()
.map(|(b, _)| String::from_utf8_lossy(&b).into_owned());
let body = serde_json::json!({
"pod": pod,
"resource": resource,
"commit": {
"sha": resolved.hash,
"parent": resolved.parent,
"agent_did": resolved.author_email,
"committer": resolved.author_name,
"subject": resolved.subject,
"committed_at": resolved.committed_at,
},
"prov_ttl": sidecar_ttl,
"anchored": sidecar_ttl.as_deref().map(|t| t.contains("bt:txid")).unwrap_or(false),
});
Ok(HttpResponse::Ok().content_type("application/json").json(body))
}
#[derive(Debug, serde::Deserialize)]
struct AnchorBody {
commit_sha: String,
#[serde(default)]
ticker: Option<String>,
}
async fn handle_anchor(
path: web::Path<String>,
req: HttpRequest,
state: web::Data<AppState>,
body: Bytes,
) -> Result<HttpResponse, ActixError> {
let pod = path.into_inner();
let owner = match require_pod_owner(&req, &pod).await {
Some(pk) => pk,
None => {
return Ok(match extract_pubkey(&req).await {
Some(_) => prov_err("not the pod owner", 403),
None => prov_err("NIP-98 authentication required", 401),
});
}
};
let did = format!("did:nostr:{owner}");
let req_body: AnchorBody = match serde_json::from_slice(&body) {
Ok(b) => b,
Err(e) => return Ok(prov_err(&format!("malformed anchor request: {e}"), 400)),
};
if req_body.commit_sha.is_empty()
|| req_body.commit_sha.len() > 64
|| !req_body.commit_sha.bytes().all(|b| b.is_ascii_hexdigit())
{
return Ok(prov_err("invalid commit_sha", 400));
}
let Some(repo) = pod_repo_path(&state, &pod) else {
return Ok(prov_err("git provenance not available", 501));
};
if !repo.join(".git").is_dir() {
return Ok(prov_err("pod is not git-backed", 404));
}
let resolved = match solid_pod_rs_git::resolve_commit(&repo, &req_body.commit_sha).await {
Ok(r) => r,
Err(e) => return Ok(prov_err(&format!("commit not found: {e}"), 404)),
};
let Some(resource_rel) = resolved.files.iter().find(|f| !is_sidecar(f)).cloned() else {
return Ok(prov_err("commit touched no content resource", 404));
};
let resource = format!("/{pod}/{resource_rel}");
let Some((anchorer, ticker, network)) =
build_anchorer(&state, req_body.ticker.as_deref()).await
else {
return Ok(prov_err(
"pod not configured for Bitcoin anchoring (no pay-token / trail not minted)",
400,
));
};
let price = anchor_price_sats(&state);
if price > 0 {
if let Err(rsp) = debit(&state, &did, price).await {
return Ok(rsp);
}
}
let anchor = match anchorer.anchor(&ticker, &resolved.hash, &network).await {
Ok(a) => a,
Err(e) => {
if price > 0 {
let _ = credit(&state, &did, price).await;
}
return Ok(prov_err(&format!("anchor failed: {e}"), 502));
}
};
let mark = ProvenanceMark {
resource: resource.clone(),
git: solid_pod_rs::provenance::GitMark {
commit_sha: resolved.hash.clone(),
repo: pod.clone(),
branch: "main".to_string(),
parent: resolved.parent.clone(),
},
anchor: Some(anchor.clone()),
agent_did: resolved.author_email.clone(),
created: resolved.committed_at,
};
let ttl = prov_ttl(&mark);
let sidecar = format!("{resource}.prov.ttl");
if let Err(e) = state
.storage
.put(&sidecar, Bytes::from(ttl.into_bytes()), "text/turtle")
.await
{
tracing::warn!(
target: "solid_pod_rs_server::prov",
sidecar = %sidecar,
"anchor sidecar rewrite failed (anchor still on-chain): {e}"
);
}
Ok(HttpResponse::Ok().content_type("application/json").json(serde_json::json!({
"pod": pod,
"resource": resource,
"commit_sha": resolved.hash,
"anchor": {
"ticker": anchor.ticker,
"txid": anchor.txid,
"vout": anchor.vout,
"address": anchor.address,
"network": anchor.network,
"state_hash": anchor.state_hash,
},
"charged_sats": price,
})))
}
fn is_sidecar(path: &str) -> bool {
path.ends_with(".acl") || path.ends_with(".meta") || path.ends_with(".prov.ttl")
}
fn anchor_price_sats(state: &AppState) -> u64 {
std::env::var("JSS_PROV_ANCHOR_PRICE_SATS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.unwrap_or_else(|| {
state
.pay_config
.token
.as_ref()
.map(|t| t.rate.max(1))
.unwrap_or(0)
})
}
async fn debit(state: &AppState, did: &str, sats: u64) -> Result<(), HttpResponse> {
use crate::handlers::pay::StoragePaymentStore;
use solid_pod_rs::payments::{PaymentError, PaymentStore};
let store = StoragePaymentStore::new(&*state.storage);
let mut ledger = store
.read_ledger()
.await
.map_err(|e| prov_err(&format!("ledger read failed: {e}"), 500))?;
if let Err(e) = ledger.debit(did, sats) {
return Err(match e {
PaymentError::InsufficientBalance { balance, cost } => HttpResponse::PaymentRequired()
.json(serde_json::json!({
"error": "Insufficient balance to anchor",
"balance": balance,
"cost": cost,
})),
other => prov_err(&format!("debit failed: {other}"), 500),
});
}
store
.write_ledger(&ledger)
.await
.map_err(|e| prov_err(&format!("ledger write failed: {e}"), 500))?;
Ok(())
}
async fn credit(state: &AppState, did: &str, sats: u64) -> Result<(), String> {
use crate::handlers::pay::StoragePaymentStore;
use solid_pod_rs::payments::PaymentStore;
let store = StoragePaymentStore::new(&*state.storage);
let mut ledger = store.read_ledger().await.map_err(|e| e.to_string())?;
ledger.credit(did, sats);
store.write_ledger(&ledger).await.map_err(|e| e.to_string())
}
fn prov_err(msg: &str, status: u16) -> HttpResponse {
HttpResponse::build(
actix_web::http::StatusCode::from_u16(status)
.unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
)
.content_type("application/json")
.json(serde_json::json!({ "error": msg }))
}
pub fn register(app: &mut web::ServiceConfig) {
app.route(
"/{pod}/_prov/anchor",
web::post().to(handle_anchor),
)
.route(
"/{pod}/_prov/{commit_sha}",
web::get().to(handle_resolve),
);
}