feat(social): embed content catalog in CreatorProfile for cold-start discovery#2
feat(social): embed content catalog in CreatorProfile for cold-start discovery#2arkavo-com wants to merge 1 commit into
Conversation
…discovery A consumer who resolves a creator's profile could not build the content catalog: featuredContentIDs carries content IDs only, there is no ID-to-ticket resolution, and ContentTicketCache.tickets(for:) just filters the local cache — so first-time consumers depended on out-of-band ticket sharing (manual paste), contradicting the catalog being the primary discovery method. - CreatorProfile.contentCatalog: [ContentTicket]? — embedded catalog of the creator's discoverable content. Optional so profiles published before this field existed keep decoding (synthesized Codable, missing key decodes as nil). Entries are public metadata by design; access stays gated at KAS rewrap. - ContentTicketCache.seed(from:) hydrates the cache from a fetched profile, refusing to roll back cached entries with a newer version. 5 tests: legacy-profile decode, catalog round-trip, cold-start seeding, version precedence, nil-catalog no-op. Adoption follow-ups tracked in #1: Creator publishes tickets into the profile catalog (with creator control over what is listed); the consumer app seeds the cache after fetchProfile. Closes #1 (foundation; app adoption tracked there). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| public func seed(from profile: CreatorProfile) -> Int { | ||
| var updated = 0 | ||
| for ticket in profile.contentCatalog ?? [] { | ||
| let key = ticket.contentID.hexString | ||
| if let existing = cache[key], existing.version >= ticket.version { | ||
| continue | ||
| } | ||
| cache[key] = ticket | ||
| updated += 1 | ||
| } | ||
| if cache.count > maxEntries { | ||
| evictOldest() | ||
| } | ||
| return updated | ||
| } |
There was a problem hiding this comment.
⚠️ Security: seed() trusts unauthenticated ticket fields, enabling cache poisoning
ContentTicketCache.seed(from:) ingests every ContentTicket from profile.contentCatalog and trusts both ticket.contentID and ticket.creatorPublicID verbatim, with no check that they belong to the profile being seeded.
Two concrete consequences:
-
Attribution spoofing: A profile for creator A can embed a ticket whose
creatorPublicIDis B. Afterseed,tickets(for: B)will return that attacker-controlled ticket, so the catalog UI attributes A's (or arbitrary) content to B. -
Cache poisoning / downgrade-resistant override: Because
seedreplaces a cached entry whenever the incomingversionis greater (existing.version >= ticket.version→ keep, otherwise overwrite), a malicious or stale-but-tampered profile can set an arbitrarily highversionand overwrite a legitimate cached ticket for an existingcontentIDwith a differentticketblob string. Theversion-precedence guard is the only protection and it is fully attacker-controllable.
Documentation argues entries are "public metadata" gated at KAS rewrap, which limits the blast radius to wrong/failed fetches rather than unauthorized decryption. But the cache is a process-wide singleton shared across all creators, so one fetched profile can affect lookups attributed to other creators. Consider validating that each ticket's creatorPublicID == profile.publicID (or the profile's identity) before caching, and/or verifying the contentID↔ticket binding before trusting a higher version.
Reject catalog entries whose creatorPublicID does not match the profile owner.:
public func seed(from profile: CreatorProfile) -> Int {
var updated = 0
for ticket in profile.contentCatalog ?? [] {
// Only trust tickets the profile is authorized to vouch for.
guard ticket.creatorPublicID == profile.publicID else { continue }
let key = ticket.contentID.hexString
if let existing = cache[key], existing.version >= ticket.version {
continue
}
cache[key] = ticket
updated += 1
}
if cache.count > maxEntries { evictOldest() }
return updated
}
- Apply fix
Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎
| public func seed(from profile: CreatorProfile) -> Int { | ||
| var updated = 0 | ||
| for ticket in profile.contentCatalog ?? [] { | ||
| let key = ticket.contentID.hexString | ||
| if let existing = cache[key], existing.version >= ticket.version { | ||
| continue | ||
| } | ||
| cache[key] = ticket | ||
| updated += 1 | ||
| } | ||
| if cache.count > maxEntries { | ||
| evictOldest() | ||
| } | ||
| return updated | ||
| } |
There was a problem hiding this comment.
💡 Edge Case: seed() may return entries that evictOldest immediately removes
seed(from:) increments updated for each ticket written, then calls evictOldest() if the cache exceeds maxEntries. evictOldest removes entries with the oldest createdAt. Catalog tickets carry their original publish time, which is typically older than freshly P2P-cached tickets, so when the cache is near capacity the just-seeded entries are the most likely to be evicted right after being added. In that case seed reports a positive updated count ("newly cached or updated") for tickets that are no longer present, and the cold-start hydration silently fails. Consider evicting before inserting, prioritizing eviction of non-seeded entries, or counting only entries that survive eviction.
Was this helpful? React with 👍 / 👎
Code Review
|
Foundation for #1 — Option A (catalog embedded in the profile).
Problem
A cold-start consumer who resolves a
CreatorProfilecannot build the content catalog:featuredContentIDscarries content IDs only,IrohContentServicehas no ID→ticket resolution, andContentTicketCache.tickets(for:)only filters the local cache. First-time consumers depended on out-of-band ticket sharing (manual paste inCreatorContentView), contradicting the catalog being the primary discovery method for TDF-protected content.Changes
CreatorProfile.contentCatalog: [ContentTicket]?— embedded catalog of the creator's discoverable content. Optional so already-published profiles keep decoding (synthesizedCodable; missing key → nil). Entries are descriptors/metadata, public by design — access stays gated at KAS rewrap by thepatreon.arkavo.compolicy, which is the intended storefront model.ContentTicketCache.seed(from:)— hydrates the cache from a fetched profile; refuses to roll back cached entries with a newerversion; respectsmaxEntrieseviction.Tests
5 new tests (swift-testing): legacy-profile decode (key stripped at JSON level), catalog round-trip through
toData/fromData(whole-second dates — ISO8601 drops sub-second precision), cold-start seeding, version precedence both directions, nil-catalog no-op. Cache tests run in a.serializedsuite with per-test IDs sinceContentTicketCacheis a process-wide singleton.Full package builds; all 5 tests pass.
Out of scope (adoption, tracked in #1)
ContentTicketto the profile catalog on "Publish to Iroh" and re-publish — needs a creator-facing decision on which items are listed.seed(from:)afterfetchProfile, beforeloadContent.🤖 Generated with Claude Code