Skip to content

feat(social): embed content catalog in CreatorProfile for cold-start discovery#2

Open
arkavo-com wants to merge 1 commit into
mainfrom
feat/profile-content-catalog
Open

feat(social): embed content catalog in CreatorProfile for cold-start discovery#2
arkavo-com wants to merge 1 commit into
mainfrom
feat/profile-content-catalog

Conversation

@arkavo-com

Copy link
Copy Markdown
Contributor

Foundation for #1 — Option A (catalog embedded in the profile).

Problem

A cold-start consumer who resolves a CreatorProfile cannot build the content catalog: featuredContentIDs carries content IDs only, IrohContentService has no ID→ticket resolution, and ContentTicketCache.tickets(for:) only filters the local cache. First-time consumers depended on out-of-band ticket sharing (manual paste in CreatorContentView), 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 (synthesized Codable; missing key → nil). Entries are descriptors/metadata, public by design — access stays gated at KAS rewrap by the patreon.arkavo.com policy, which is the intended storefront model.
  • ContentTicketCache.seed(from:) — hydrates the cache from a fetched profile; refuses to roll back cached entries with a newer version; respects maxEntries eviction.

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 .serialized suite with per-test IDs since ContentTicketCache is a process-wide singleton.

Full package builds; all 5 tests pass.

Out of scope (adoption, tracked in #1)

  • Creator: append the ContentTicket to the profile catalog on "Publish to Iroh" and re-publish — needs a creator-facing decision on which items are listed.
  • app (consumer): call seed(from:) after fetchProfile, before loadContent.

🤖 Generated with Claude Code

…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>
Comment on lines +102 to +116
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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 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:

  1. Attribution spoofing: A profile for creator A can embed a ticket whose creatorPublicID is B. After seed, tickets(for: B) will return that attacker-controlled ticket, so the catalog UI attributes A's (or arbitrary) content to B.

  2. Cache poisoning / downgrade-resistant override: Because seed replaces a cached entry whenever the incoming version is greater (existing.version >= ticket.version → keep, otherwise overwrite), a malicious or stale-but-tampered profile can set an arbitrarily high version and overwrite a legitimate cached ticket for an existing contentID with a different ticket blob string. The version-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 👍 / 👎

Comment on lines +102 to +116
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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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 👍 / 👎

@gitar-bot

gitar-bot Bot commented Jun 11, 2026

Copy link
Copy Markdown
Code Review ⚠️ Changes requested 0 resolved / 2 findings

Embeds the content catalog in CreatorProfile to improve discovery, but contains cache poisoning vulnerabilities via unauthenticated ticket seeding and inefficient cache eviction logic.

⚠️ Security: seed() trusts unauthenticated ticket fields, enabling cache poisoning

📄 Sources/ArkavoSocial/ContentTicketCache.swift:102-116

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:

  1. Attribution spoofing: A profile for creator A can embed a ticket whose creatorPublicID is B. After seed, tickets(for: B) will return that attacker-controlled ticket, so the catalog UI attributes A's (or arbitrary) content to B.

  2. Cache poisoning / downgrade-resistant override: Because seed replaces a cached entry whenever the incoming version is greater (existing.version >= ticket.version → keep, otherwise overwrite), a malicious or stale-but-tampered profile can set an arbitrarily high version and overwrite a legitimate cached ticket for an existing contentID with a different ticket blob string. The version-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
}
💡 Edge Case: seed() may return entries that evictOldest immediately removes

📄 Sources/ArkavoSocial/ContentTicketCache.swift:102-116 📄 Sources/ArkavoSocial/ContentTicketCache.swift:144-150

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.

🤖 Prompt for agents
Code Review: Embeds the content catalog in CreatorProfile to improve discovery, but contains cache poisoning vulnerabilities via unauthenticated ticket seeding and inefficient cache eviction logic.

1. ⚠️ Security: seed() trusts unauthenticated ticket fields, enabling cache poisoning
   Files: Sources/ArkavoSocial/ContentTicketCache.swift:102-116

   `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:
   
   1. **Attribution spoofing**: A profile for creator A can embed a ticket whose `creatorPublicID` is B. After `seed`, `tickets(for: B)` will return that attacker-controlled ticket, so the catalog UI attributes A's (or arbitrary) content to B.
   
   2. **Cache poisoning / downgrade-resistant override**: Because `seed` replaces a cached entry whenever the incoming `version` is greater (`existing.version >= ticket.version` → keep, otherwise overwrite), a malicious or stale-but-tampered profile can set an arbitrarily high `version` and overwrite a legitimate cached ticket for an existing `contentID` with a different `ticket` blob string. The `version`-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.

   Fix (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
   }

2. 💡 Edge Case: seed() may return entries that evictOldest immediately removes
   Files: Sources/ArkavoSocial/ContentTicketCache.swift:102-116, Sources/ArkavoSocial/ContentTicketCache.swift:144-150

   `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 👍 / 👎 | Gitar

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant