fix(content): owner never pays for their own files; purchased serves from cache

- serve_content takes owner_session: a validated operator session skips the
  availability/paid gates (Availability::Nobody stays delisted); the cookie
  is re-validated in the content handler, same discipline as the model proxy
- the Tor proxy serves already-purchased items from the local content_owned
  cache with Range slicing (206) instead of re-hitting the seller's 402 —
  the buyer-side store exists so an owned item is never bought twice, and
  its cards were rendering as permanent placeholders
- adapter: 'own'-scope items never render locked (a locked card suppresses
  the playable URL — the placeholder-only grid the operator reported)
- broker: normalize 'purchased' OwnedRpcItems per item with the seller's
  onion, and group 'peers' items per seller onion, so buildMediaUrl gets a
  peerOnion and card URLs stop coming out empty

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-07 08:40:49 -04:00
co-authored by Claude
parent f0d2cdb7b2
commit c25fd8b650
8 changed files with 230 additions and 26 deletions
@@ -47,6 +47,7 @@ impl ApiHandler {
}
pub(super) async fn handle_content_request(
&self,
path: &str,
headers: &hyper::HeaderMap,
config: &Config,
@@ -87,6 +88,16 @@ impl ApiHandler {
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// The authenticated local operator never pays for their own node's
// content: validate the session cookie (same discipline as the model
// proxy — re-derived here, never trusted to the front door) and hand
// serve_content the owner bypass. No cookie / bad session is simply
// the buyer path, unchanged.
let owner_session = match crate::session::extract_session_cookie(headers) {
Some(token) => self.session_store.validate(&token).await,
None => false,
};
// Parse Range header for streaming support
let range = headers
.get("range")
@@ -100,6 +111,7 @@ impl ApiHandler {
invoice_hash.as_deref(),
peer_did.as_deref(),
range,
owner_session,
)
.await
{
+3 -2
View File
@@ -568,9 +568,10 @@ impl ApiHandler {
self.handle_content_onchain(p).await
}
// Content serving — peers access shared content over Tor (no session auth)
// Content serving — peers access shared content over Tor (no session auth);
// a valid operator session cookie unlocks the owner path inside.
(Method::GET, p) if p.starts_with("/content/") => {
Self::handle_content_request(p, &headers, &self.config).await
self.handle_content_request(p, &headers, &self.config).await
}
// Content catalog — list available content (no session auth, for peers)
+47
View File
@@ -225,6 +225,53 @@ impl ApiHandler {
return bad("invalid onion or content id");
}
// Already purchased? Serve the local cache — no network, no
// re-payment. The seller's node charges every fetch by design; the
// buyer-side store (content_owned) exists precisely so an owned item
// never has to be bought twice, and the content surface's cards were
// hitting the seller's 402 and rendering as permanent placeholders.
// Range is honoured by slicing, so seek/playback works from cache.
if crate::content_owned::is_owned(&self.config.data_dir, onion, content_id).await {
if let Some((mime_type, bytes)) =
crate::content_owned::read_owned(&self.config.data_dir, onion, content_id).await
{
let total = bytes.len();
let range = headers
.get("range")
.and_then(|v| v.to_str().ok())
.and_then(crate::content_server::parse_range_header);
if let Some(r) = range {
let start = (r.start as usize).min(total);
let end = r
.end
.map(|e| e as usize)
.unwrap_or(total.saturating_sub(1))
.min(total.saturating_sub(1));
if start <= end && total > 0 {
let slice = &bytes[start..=end];
return Ok(Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header("Content-Type", mime_type)
.header("Content-Length", slice.len().to_string())
.header("Content-Range", format!("bytes {}-{}/{}", start, end, total))
.header("Accept-Ranges", "bytes")
.body(hyper::Body::from(slice.to_vec()))
.unwrap_or_else(|_| Response::new(hyper::Body::empty())));
}
}
return Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", mime_type)
.header("Content-Length", total.to_string())
.header("Accept-Ranges", "bytes")
.body(hyper::Body::from(bytes))
.unwrap_or_else(|_| Response::new(hyper::Body::empty())));
}
// Indexed as owned but bytes missing — fall through to the peer
// rather than erroring: the seller can still serve it (for the
// price already paid, the operator can re-fetch and re-cache).
}
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let peer_path = format!("/content/{}", content_id);
// Generous overall timeout: this endpoint serves both seek/Range
+27 -12
View File
@@ -250,6 +250,7 @@ pub async fn serve_content(
invoice_hash: Option<&str>,
peer_did: Option<&str>,
range: Option<ByteRange>,
owner_session: bool,
) -> Result<ServeResult> {
let catalog = load_catalog(data_dir).await?;
let item = match catalog.items.iter().find(|i| i.id == id) {
@@ -257,6 +258,16 @@ pub async fn serve_content(
None => return Ok(ServeResult::NotFound),
};
// The authenticated local operator never pays for — and is never fenced
// out of — their own node's content. The paid/peers-only gates exist for
// buyers and peers on OTHER nodes; charging the owner for their own file
// made the owner's own dashboard render 402s and lock overlays on their
// own photos. `Availability::Nobody` still means delisted: not served
// even here.
if owner_session && matches!(item.availability, Availability::Nobody) {
return Ok(ServeResult::NotFound);
}
// Load known federation peers for access checks
let is_known_peer = if peer_did.is_some() {
let nodes = crate::federation::load_nodes(data_dir)
@@ -268,23 +279,26 @@ pub async fn serve_content(
};
// Check availability
match &item.availability {
Availability::Nobody => return Ok(ServeResult::NotFound),
Availability::Specific { peers } => {
if let Some(did) = peer_did {
if !peers.iter().any(|p| p == did) {
debug!("Content '{}' not available to peer {}", id, did);
if !owner_session {
match &item.availability {
Availability::Nobody => return Ok(ServeResult::NotFound),
Availability::Specific { peers } => {
if let Some(did) = peer_did {
if !peers.iter().any(|p| p == did) {
debug!("Content '{}' not available to peer {}", id, did);
return Ok(ServeResult::Forbidden);
}
} else {
return Ok(ServeResult::Forbidden);
}
} else {
return Ok(ServeResult::Forbidden);
}
Availability::AllPeers => {}
}
Availability::AllPeers => {}
}
// Check access control
match &item.access {
if !owner_session {
match &item.access {
AccessControl::Paid { price_sats, .. } => {
// Two ways to satisfy payment:
// (a) a valid ecash token (the local-wallet fast path), or
@@ -319,6 +333,7 @@ pub async fn serve_content(
}
}
AccessControl::Free => {}
}
}
let file_path = content_file_path(data_dir, item);
@@ -651,7 +666,7 @@ mod prune_missing_content_tests {
.unwrap();
// File was never written to disk under content/files/ or filebrowser/.
let result = serve_content(data_dir, "missing-item", None, None, None, None)
let result = serve_content(data_dir, "missing-item", None, None, None, None, false)
.await
.unwrap();
assert!(matches!(result, ServeResult::NotFound));
@@ -701,7 +716,7 @@ mod prune_missing_content_tests {
.await
.unwrap();
let _ = serve_content(data_dir, "missing-item", None, None, None, None)
let _ = serve_content(data_dir, "missing-item", None, None, None, None, false)
.await
.unwrap();
@@ -98,11 +98,21 @@ describe('adaptContentItems', () => {
expect(bundle.images[0]!.url).toBe('/content/img-1')
})
it('locks a paid image: price carried, no URL to fetch bytes the user has not bought', () => {
it('never locks an OWN paid image: the node serves the authenticated owner (owner-bypass), price stays as a badge', () => {
const bundle = adaptContentItems(
[item({ id: 'p-1', filename: 'photo.jpg', mime_type: 'image/jpeg', access: { paid: { price_sats: 100 } } })],
{ source: 'own' },
)
expect(bundle.images[0]!.locked).toBe(false)
expect(bundle.images[0]!.priceSats).toBe(100)
expect(bundle.images[0]!.url).toBe('/content/p-1')
})
it('locks a PEER paid image: price carried, no URL to fetch bytes the user has not bought', () => {
const bundle = adaptContentItems(
[item({ id: 'p-1', filename: 'photo.jpg', mime_type: 'image/jpeg', access: { paid: { price_sats: 100 } } })],
{ source: 'peer', peerOnion: 'seller.onion' },
)
expect(bundle.images[0]!.locked).toBe(true)
expect(bundle.images[0]!.priceSats).toBe(100)
expect(bundle.images[0]!.url).toBe('')
@@ -314,7 +314,12 @@ function buildMediaUrl(item: ArchyContentItem, opts: AdaptContentOptions): strin
export function adaptToFilm(item: ArchyContentItem, opts: AdaptContentOptions): Film {
const priceSats = paidPriceSats(item.access)
const locked = priceSats !== null
// 'own' items are served to the authenticated owner by the node's
// owner-bypass (`serve_content`) even when they're listed paid for
// buyers — the operator never pays for their own files, so never lock
// them (a locked card suppresses the playable URL, which is exactly the
// placeholder-only grid the operator reported).
const locked = opts.source !== 'own' && priceSats !== null
const sourceType = FILM_SOURCE_TYPE[opts.source]
return {
id: item.id,
@@ -348,7 +353,12 @@ export function adaptToFilm(item: ArchyContentItem, opts: AdaptContentOptions):
*/
export function adaptToImage(item: ArchyContentItem, opts: AdaptContentOptions): ImageItem {
const priceSats = paidPriceSats(item.access)
const locked = priceSats !== null
// 'own' items are served to the authenticated owner by the node's
// owner-bypass (`serve_content`) even when they're listed paid for
// buyers — the operator never pays for their own files, so never lock
// them (a locked card suppresses the playable URL, which is exactly the
// placeholder-only grid the operator reported).
const locked = opts.source !== 'own' && priceSats !== null
const title = stripExtension(item.filename || '')
return {
id: item.id,
@@ -366,7 +376,12 @@ export function adaptToImage(item: ArchyContentItem, opts: AdaptContentOptions):
export function adaptToSong(item: ArchyContentItem, opts: AdaptContentOptions): Song {
const priceSats = paidPriceSats(item.access)
const locked = priceSats !== null
// 'own' items are served to the authenticated owner by the node's
// owner-bypass (`serve_content`) even when they're listed paid for
// buyers — the operator never pays for their own files, so never lock
// them (a locked card suppresses the playable URL, which is exactly the
// placeholder-only grid the operator reported).
const locked = opts.source !== 'own' && priceSats !== null
const sourceType = SONG_SOURCE_TYPE[opts.source]
return {
id: item.id,
@@ -391,7 +406,12 @@ export function adaptToSong(item: ArchyContentItem, opts: AdaptContentOptions):
* rather than a song. */
export function adaptToPodcast(item: ArchyContentItem, opts: AdaptContentOptions): Podcast {
const priceSats = paidPriceSats(item.access)
const locked = priceSats !== null
// 'own' items are served to the authenticated owner by the node's
// owner-bypass (`serve_content`) even when they're listed paid for
// buyers — the operator never pays for their own files, so never lock
// them (a locked card suppresses the playable URL, which is exactly the
// placeholder-only grid the operator reported).
const locked = opts.source !== 'own' && priceSats !== null
const sourceType = PODCAST_SOURCE_TYPE[opts.source]
return {
id: item.id,
@@ -361,4 +361,81 @@ describe('ContextBroker', () => {
)
})
})
describe('adaptChatSurfaces', () => {
const callAdapt = (surfaces: unknown) =>
(
broker as unknown as {
adaptChatSurfaces: (s?: unknown) => { tool: string; scope?: string; bundle: { films: unknown[]; songs: unknown[]; podcasts: unknown[]; images: { id: string; url: string; locked: boolean }[] } }[] | undefined
}
).adaptChatSurfaces(surfaces)
// Live bug (archi-dev-box 2026-08-07): a purchased-scope chat surface
// adapted the OwnedRpcItem wire shape as if it were ArchyContentItem —
// id came out undefined, peerOnion was never passed, every URL was ''
// — so three purchased images rendered as placeholders beside a correct
// prose answer.
it('purchased scope normalizes owned items and builds per-seller URLs', () => {
const perms = useAIPermissionsStore()
perms.enableAll()
const out = callAdapt([
{
tool: 'content_list',
scope: 'purchased',
data: {
items: [
{
onion: 'peer-one.onion',
content_id: 'cid-1',
filename: 'signal-test.jpeg',
mime_type: 'image/jpeg',
size_bytes: 170000,
paid_sats: 100,
purchased_at: '2026-06-20T00:00:00Z',
},
{
onion: 'peer-two.onion',
content_id: 'cid-2',
filename: 'got it!.jpg',
mime_type: 'image/jpeg',
size_bytes: 253000,
paid_sats: 100,
purchased_at: '2026-08-04T00:00:00Z',
},
],
},
},
])
expect(out).toHaveLength(1)
const images = out![0]!.bundle.images
expect(images).toHaveLength(2)
// Real ids, real per-seller URLs, and already-paid items are unlocked.
expect(images.map((i) => i.id)).toEqual(expect.arrayContaining(['cid-1', 'cid-2']))
expect(images[0]!.url).toBe('/api/peer-content/peer-one.onion/cid-1')
expect(images[1]!.url).toBe('/api/peer-content/peer-two.onion/cid-2')
expect(images.every((i) => !i.locked)).toBe(true)
})
it('peers scope adapts per-seller so every item URL carries its own onion', () => {
const perms = useAIPermissionsStore()
perms.enableAll()
const out = callAdapt([
{
tool: 'content_list',
scope: 'peers',
data: {
items: [
{ id: 'x', filename: 'a.jpg', mime_type: 'image/jpeg', size_bytes: 1, peer: 'seller-a.onion' },
{ id: 'y', filename: 'b.jpg', mime_type: 'image/jpeg', size_bytes: 1, peer: 'seller-b.onion' },
],
},
},
])
const images = out![0]!.bundle.images
expect(images.map((i) => i.url).sort()).toEqual([
'/api/peer-content/seller-a.onion/x',
'/api/peer-content/seller-b.onion/y',
])
})
})
})
+29 -7
View File
@@ -307,13 +307,35 @@ export class ContextBroker {
if (!items.length) return []
// The adapter uses `source` to decide the badge and how a playable
// URL is built, so a wrong value renders a peer's paid item as
// freely local — map each scope to what it actually is.
const source =
s.scope === 'peers' || s.scope === 'purchased'
? 'peer'
: s.scope === 'films'
? 'indeehub'
: 'own'
// freely local — map each scope to what it actually is. Two scopes
// need more than a label:
//
// `purchased` arrives as OwnedRpcItem (content_id/onion/paid_sats),
// not ArchyContentItem — normalize per item AND carry the seller's
// onion, or the card adapts with id=undefined and url='' and renders
// as a permanent placeholder (the live "shows a placeholder" bug).
if (s.scope === 'purchased') {
const bundles = (items as unknown as OwnedRpcItem[]).map((owned) =>
adaptContentItems([normalizeOwnedItem(owned)], { source: 'peer', peerOnion: owned.onion }),
)
return [{ tool: s.tool, scope: s.scope, bundle: mergeBundles(bundles) }]
}
// `peers` items each carry their seller's onion (the node stamps
// `peer` per item in the fan-out) — adapt per-peer or every URL
// comes out '' (`buildMediaUrl` refuses peer URLs without one).
if (s.scope === 'peers') {
const byOnion = new Map<string, ArchyContentItem[]>()
for (const it of items) {
const onion = typeof (it as { peer?: unknown }).peer === 'string' ? (it as { peer: string }).peer : ''
if (!onion) continue
byOnion.set(onion, [...(byOnion.get(onion) ?? []), it])
}
const bundles = [...byOnion.entries()].map(([onion, its]) =>
adaptContentItems(its, { source: 'peer', peerOnion: onion }),
)
return [{ tool: s.tool, scope: s.scope, bundle: mergeBundles(bundles) }]
}
const source = s.scope === 'films' ? 'indeehub' : 'own'
return [{ tool: s.tool, scope: s.scope, bundle: adaptContentItems(items, { source }) }]
})
return adapted.length ? adapted : undefined