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
@@ -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,