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:
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user