Skip to content

ActivityPub federation: design spec for a federated marketplace #1096

Description

@fballiano

ActivityPub Federation for Maho - Design Spec

Status: ready to implement - all decisions made. Architecture, implementation spec, and every
product decision are settled (§1-14 + Appendices A-G; §12 forks all resolved). No open questions
remain within Maho's scope. This document captures the design for federating a Maho store into a
federated marketplace via ActivityPub.


1. Goal & scope

Let a Maho store publish into the Fediverse - two distinct streams for two distinct audiences:

  • Blog → humans. Mastodon / generic Fediverse users follow the store's blog and receive
    its articles in their timeline. This is the store's editorial/social presence. A new article is
    a genuine social event (a new post); a product price change is not - so products are never pushed
    into human timelines
    (see §3/§4).
  • Catalog → machines. A new third-party marketplace aggregator (does not exist yet - we define
    both ends of the contract) ingests, indexes and presents products (Offer) across many stores.

Maho acts as a pure publisher. It emits both streams and a minimal social graph; it does not
consume other catalogs.

2. Non-goals

  • No Maho ↔ Maho federation. No other Maho instance is expected to consume our catalog, so
    there is no catalog ingest on the Maho side: no aggregated marketplace view inside Maho, no
    storage of remote objects, no bidirectional catalog sync.
  • No transactions over ActivityPub. Buying is a link-out to the seller's own Maho checkout
    (see §9). No escrow, no distributed transaction, no shared inventory.
  • No inbound social content on the storefront. Replies/comments from the Fediverse are never
    mirrored onto the Maho site (see §10).

3. Actor model - two sibling actors per store view

Each store view exposes two actors, one per audience. They are not two symmetric choices a
user picks between
- their discoverability differs, so the right consumer reaches the right one
by construction:

Actor AP type Emits Followed by Discoverability
Website (account) Organization - nobody (trust anchor) identity only, attributedTo target
Store view → blog Service/Person Article humans / Mastodon public: WebFinger resolves the store handle @negozio_de@domain to this
Store view → catalog Service Offer the aggregator machine-only: linked in the blog actor's document, not advertised to humans

How each party reaches the right stream - no explicit choice, it's structural:

human on Mastodon:  search "negozio" → WebFinger → @negozio_de (the BLOG) → Follow → Article stream
                    (never sees the catalog actor - and that's fine)

aggregator:         WebFinger negozio.com → read @negozio_de actor document
                    → find machine link  → @negozio_de_shop (the CATALOG) → Follow → Offer stream
  • The store's public face is the blog. A human doesn't "choose the blog" - the store handle
    resolves to it. Products are never in the human timeline.
  • The catalog is a sibling actor discovered programmatically, exposed only as a link in the blog
    actor's document (+ the catalog collection, §6). A curious human who found and followed it would
    just receive raw Offers - harmless, simply not meant for them.
  • Both actors declare attributedTo → the account (website), so an aggregator collapses the
    language/stream variants of one seller into a single identity (PeerTube/Lemmy "account + channels").
  • Why per store view: localized content (article text, product name/description/URL-key) is
    store-view-scoped in Maho; users follow in their language.
  • Rejected alternative - one actor, delivery scoped by follower type: the publisher would have to
    guess whether a follower is a human or an aggregator and segment delivery. Magic, fragile, and a
    human couldn't opt into the catalog even if they wanted. Two actors make the split structural.

4. Object model - two object types

Two streams (§3) ⇒ two object types. Because products are no longer pushed into human timelines,
the earlier "make a product look decent as a Mastodon Note" problem disappears: Article is the
human timeline content; Offer is clean structured data for the aggregator.

4.1 Blog post → Article (blog actor → humans)

Maps 1:1 from blog_post_entity (EAV, store-scoped): titlename, contentcontent,
url_keyurl, publish_date/created_atpublished, image attribute→attachment,
categories/tags→Tag, store_id→the blog actor. This is genuine social content, so Create = a
real new post + notification is desirable (unlike products).

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "type": "Article",
  "attributedTo": "https://seller/ap/store/negozio_de",   // the blog actor
  "name": "Come scegliere la taglia giusta",
  "content": "<p>…</p>",
  "url": "https://seller/de/blog/taglie",
  "published": "2026-07-09T10:00:00Z",
  "attachment": [{ "type": "Image", "url": "https://seller/media/blog/taglie.jpg" }],
  "tag": [{ "type": "Hashtag", "name": "#guida" }]
}

4.2 Product → Offer (catalog actor → aggregator)

Structured commerce data, not a timeline post. @context reuses schema.org where it exists
(Offer, price, priceCurrency, availability, sku, gtin, brand, Rating) and mints new
terms only for the gaps in a custom open context …/ns/commerce/v1:

  • commerce:canonicalProduct - stable cross-language product URI (dedup collant)
  • finer federated stock semantics beyond InStock/OutOfStock (lead time, backorder)
  • shared marketplace taxonomy / category mapping
  • seller identity proof / reputation at account level
{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    "https://schema.org/",
    "https://maho.example/ns/commerce/v1"
  ],
  "type": "Offer",
  "attributedTo": "https://seller/ap/store/negozio_de_shop", // the catalog actor
  "name": "Rotes T-Shirt",
  "url": "https://seller/de/rotes-t-shirt",                  // link-out (payment happens here)
  "attachment": [{ "type": "Image", "url": "https://seller/media/ts-1234.jpg" }],
  "schema:price": "19.90", "schema:priceCurrency": "EUR",
  "schema:availability": "schema:InStock",
  "schema:sku": "TS-1234",
  "commerce:canonicalProduct": "https://seller/ap/product/TS-1234",
  "commerce:sellerAccount": "https://seller/ap/account/negozio",
  "commerce:category": "apparel/tshirts"
}

4.3 Object identity - HARD INVARIANT (both types)

Guarantees a change never appears as a new object or a new post:

  • The object id (object_uri) is stable for the object's entire federated life and derived
    only from immutable keys - store_id + entity entity_id (product or blog post). Never from
    slug/URL-key, name, price, or any mutable attribute. (Deriving it from the slug would make the object
    "reincarnate" as new when the slug changes - the exact bug to prevent.)
  • Generated once (when the activitypub_object row is created) and never regenerated.
  • A price/stock/content edit reuses that same id, published as Update → Mastodon edits in place
    (no new timeline entry, no notification); the aggregator updates the existing record.
    commerce:canonicalProduct is likewise stable, so language variants stay grouped.
  • Only a physical Delete (Tombstone) ends an id. A later genuinely-new object gets a new id -
    correctly new. Out-of-stock does not Delete (§5), so it never triggers this.

Aggregator ingest requirement (we own the spec): consumers MUST upsert keyed by object id -
an activity whose object id is already known is an edit, not an insert. Group language variants
by commerce:canonicalProduct, but keep each per-channel object as its own in-place-editable record.
(Mastodon does this natively for Update.)

5. Activities & lifecycle mapping (outbox)

Blog actor (→ humans; new posts are wanted):

Maho event AP activity Object Notes
Article published (is_active) Create Article genuine new post + notification - desirable
Article edited Update Article edit in place
Article unpublished / deleted Delete Tombstone

Catalog actor (→ aggregator; silent, no timeline noise):

Maho event AP activity Object Notes
Product becomes visible + enabled + in stock in a view Create Offer first federated publish (silent - no human timeline)
Name/description/image change Update Offer
Price change Update Offer debounce + threshold (see below)
Stock/availability change Update Offer schema:availability InStock/OutOfStock - object stays in feeds
Disabled / no longer visible in that view Update Offer (OutOfStock/hidden) temporary down
Physically deleted, or permanently retired Delete Tombstone irreversible

Both actors answer an incoming Follow with Accept (OPEN: auto vs moderated).

Notification semantics. Create = a new post (new timeline entry + notification); Update =
edit of the same object (stable object_uri) applied in place by Mastodon - no new entry, no
re-notification, at most an "edited" marker.

  • Blog: a Create is meant to be a new post - that's the whole point of the editorial stream.
  • Catalog: Create fires once at first federation; every later change is an Update on the
    same id, so a price/stock change edits the existing object and never spams a new one. (And it
    isn't in a human timeline at all - it only reaches the aggregator.)

Consequence for debounce (§5 below / Appendix D): since Update is a silent edit, debounce is
not about preventing notification spam (there is none) - it exists to limit delivery load and
aggregator reprocessing
.

Update distribution (decided): (a) - delivered to all followers over AP. Pure, canonical AP:
publish the edit to your followers, each decides what to do (Mastodon edits in place, the aggregator
reprocesses). Scoping price/stock-only Updates to "commerce-aware" followers only - option (b) - is
a deferred fan-out optimization for when delivery volume bites; it stays pure AP either way. Re-
reading via Maho's API - option (c) - is rejected: it breaks self-sufficiency over AP (§8).

Decided policies:

  • Stock/visibility → Update + availability, not Delete. Delete only for physical delete
    or permanent retirement. (Keeps object identity so a returning product doesn't get a new id and
    lose its social history.) Needs a field distinguishing "temporarily down" from "retired".
  • Price-change spam control → debounce + threshold. Coalesce per-product price Updates into
    a time window (e.g. max 1/hour/product) and only emit if the change exceeds X%.
    OPEN: exact window and threshold; how "live" the federated price must be.
  • Federation is opt-in. A product attribute federate (default from config) plus a
    per-category override decides what is exposed. OPEN: confirm granularity (per-product flag +
    category override vs global flag with exceptions).

6. Collections

Each actor exposes outbox, followers, following (required).

  • Blog actor: outbox of Article activities; optional featured (pinned articles).
  • Catalog actor: outbox of Offer activities plus a paginated catalog collection
    (required)
    - the primary backfill so any AP-only consumer cold-starts over AP (§8); optional
    featured (featured products → a Maho featured category). The Maho API is only an optional
    accelerator, never the source of truth.

7. Topology

Publisher-only, two streams from two sibling actors per store view:

  MAHO store view
  ├─ BLOG actor  @negozio_de       ─ Article ─→  MASTODON / humans   (new post = timeline entry)
  └─ CATALOG actor @negozio_de_shop ─ Offer ───→  AGGREGATOR          (structured; never a human timeline)
       both actors → attributedTo → ACCOUNT (website) = identity / trust anchor

  inbox (minimal, per actor):  Follow / Undo(Follow) / Like / Announce
  • Humans discover and follow the blog actor (the store's public handle). The catalog actor is
    discovered programmatically by the aggregator via a link in the blog actor's document (§3).

8. Backfill vs delta

Everything is fully available over ActivityPub alone - a consumer that speaks only AP (not
Maho's API) can cold-start and stay fresh. This is a hard principle: leaning on Maho's REST/GraphQL
API for freshness or backfill would make it "not really federated" and couple consumers to Maho.

  • Bulk / backfill → the paginated AP catalog Collection (§6). Primary, mandatory mechanism;
    any AP-only consumer cold-starts by reading it. Keeps the system self-describing over AP.
  • Delta / live → ActivityPub outbox (Create/Update/Delete) + delivery carries only changes,
    plus the social graph (Follow/Announce).
  • Discovery flow: WebFinger → actor → read catalog Collection (backfill) → Follow for the delta.
  • Optional accelerator (never required): a consumer that also speaks Maho's API MAY bulk-pull
    via Maho\ApiPlatform for a faster cold start. Pure optimization - freshness and backfill are
    complete over AP without it.

9. Payments

Link-out to the seller's Maho checkout (GNU Taler / flohmarkt model). The federated object's
url points to the seller's PDP/checkout; the order is a normal Maho order on the seller
instance. No payment or order protocol over ActivityPub. This removes the entire distributed-
transaction problem.

10. Inbox behavior

Minimal, accept-and-drop, act only on Follow/Undo:

Follow          → verify signature → register follower, reply Accept
Undo(Follow)    → verify signature → remove follower
Like / Announce → 202; count only if surfaced in admin (then verify), else drop without verifying
Create (reply)  → 202, drop
anything else   → 202, drop
  • Always return 202 Accepted (not an error) even when dropping - an erroring inbox makes remote
    instances retry or mark us dead.
  • Inbound content is never published on the storefront. No replies collection exposed. The
    Fediverse reply still lives on the sender's instance.
  • Optional admin-only engagement counter: aggregate Like/boost counts per product, visible in
    the Maho admin as a marketing metric - no text, no PII, no moderation. Off by default.

11. Signature verification (lazy)

HTTP Signatures verified only for the activities we act on:

  • Follow / Undomust verify. An unsigned/forged Follow lets an attacker subscribe a
    victim to our firehose (amplification DoS) or unsubscribe real followers. Verification is the
    auth for the activities that mutate the delivery list.
  • Like / Announce → verify only if counted in admin (else trivially spoofable); otherwise drop
    unverified.
  • Pure-drop activities (replies, everything else) → no verification.

12. Product decisions

Decided:

  • Follow acceptance → auto-accept. activitypub/follow/auto_accept = yes. A moderation queue
    stays an optional, off-by-default feature (§10 admin UI).
  • Federation scope → configurable mode (activitypub/catalog/federation_mode, store-view scope):
    • all - every visible + active product federates (zero setup, max reach)
    • per_product - only products with the activitypub_federate attribute on (curated)
      Default per_product. (A category-level override is a deferred nicety, not v1.)
  • Published price → exactly as the storefront displays it. Follow the store view's tax-display
    config (incl./excl.); multi-currency store views each publish their own price (separate catalog
    actors). No separate incl/excl toggle.
  • Price freshness → low-traffic. debounce_window = 3600 (1h), price_threshold_pct = 10.
  • Aggregator relationship → both directions supported.
    • Outbound (store → hub): the merchant configures activitypub/aggregator/announce_target and the
      store announces/follows that hub - an explicit, consent-friendly opt-in.
    • Inbound (hub → store): any aggregator may discover the store (WebFinger) and Follow the catalog
      actor; because Follow is auto-accept this "just works", gated by the instance allow/deny-list
      (security/instance_denylist; optional stricter security/instance_allowlist).

Out of scope (not a Maho decision):

  • Aggregator human side - AP Announce feed vs a search site over ingested data; affects only
    whether Maho must support being boosted, which it already does (inbox accepts Announce, §10).

13. OPEN - implementation spec not yet written

  • Data model / tablesresolved, see Appendix A.
  • HTTP Signatures detailsresolved, see Appendix C.
  • Delivery queue mechanicsresolved, see Appendix D.
  • Endpoints / routingresolved, see Appendix E.
  • Content mappingresolved, see Appendix F.
  • Security hardening: SSRF protection on outbound key/object fetches, inbox rate limiting
    (reuse Mage::helper('core')->rateLimiter()), object size caps, instance allow/deny lists.
  • Config & adminresolved, see Appendix B.
  • Module skeletonresolved, see Appendix G.

14. Effort (indicative)

Phase Estimate
Publisher + Mastodon (actors, outbox, signatures, delivery) 3-5 weeks
Minimal social inbox (Follow/Undo, optional counter) ~1 week
Aggregator integration (API backfill + AP delta) 1-2 weeks
Total for complete, federated MVP ~6-8 weeks

Dropping Maho↔Maho federation and on-AP transactions is what turns this from a 6-month
research-tinged project into a ~6-8 week build.


Appendix A - Data model

Module Maho\ActivityPub. Tables prefixed activitypub_, created via install/upgrade scripts
(Doctrine DBAL). Six tables + one product/category attribute - the actor is not a table
(see A.1). Publisher-only scope keeps this small: no remote-object storage, only a key-
verification cache and the outbound machinery.

A.1 Actor - config + derived identity, not a table

An actor is not stored as a row. There are three actor kinds: blog and catalog (each per
store view store_id) and account (per website website_id). Each is a Maho scope + a keypair;
almost everything else is derived from existing store config:

Attribute Source
actor URI computed: base_url + fixed AP path + scope code + actor kind
key_id computed: {actor_uri}#main-key
attributedTo computed: blog/catalog actor → its website's account URI (store→website)
preferredUsername computed from store code + kind (config override optional)
enablement config activitypub/blog/enabled, activitypub/catalog/enabled (store-view scope)
keypair the only stored state, per actor kind - see below

The keypair is the only irreducible state, and it is per actor (blog and catalog each sign
their own deliveries). Store in core_config_data using the encrypted backend model - the
same mechanism Magento uses for payment-gateway secrets (ciphertext in the config cache, decrypted
on demand). Generated on first enable of that actor; rotation overwrites.

  • activitypub/blog/public_key / activitypub/blog/private_key (encrypted), store-view scope
  • activitypub/catalog/public_key / activitypub/catalog/private_key (encrypted), store-view scope
  • activitypub/account/public_key / activitypub/account/private_key (encrypted), website scope

Operational tables below therefore key on store_id + actor_kind (the natural scope every row
already carries), not on an actor foreign key - the actor identity is resolved from that pair via
config at runtime, exactly like any store-scoped setting.

Optional variant: if large key blobs in core_config_data are unwanted, a minimal
activitypub_key (scope, scope_id, public_key, private_key) table holds only the keypairs while
everything else stays derived/config. Either way there is no full "actor" table.

A.2 activitypub_object

Current published representation of a federated entity - one row per (store view, actor kind,
entity)
, covering both products (Offer, catalog actor) and blog posts (Article, blog
actor). Holds exactly the fields needed for change detection / debounce so we don't re-emit
Update noise. Product-only fields are NULL for articles.

Column Type Notes
entity_id INT PK AI
store_id INT the store view
object_type ENUM(product,article) selects catalog vs blog actor + object shape
source_entity_id INT catalog_product_entity or blog_post_entity
object_uri VARCHAR(255) UNIQUE stable AP id (derived from immutable keys, §4.3)
canonical_product_uri VARCHAR(255) NULL product only - account-level cross-language dedup id
content_hash CHAR(64) SHA-256 of rendered content → detect content change
last_price DECIMAL(12,4) NULL product only - price threshold check
last_availability VARCHAR(32) NULL product only - InStock / OutOfStock
last_published_at DATETIME NULL for debounce window
is_dirty TINYINT(1) set by the save observer; picked up by the coalescer (Appendix D)
dirtied_at DATETIME NULL when it went dirty → drives the debounce window
state ENUM(active,out_of_stock,retired) distinguishes temporary-down from retired
created_at / updated_at DATETIME

Unique: (store_id,object_type,source_entity_id). Index: (canonical_product_uri).

A.3 activitypub_activity

Append-only log of emitted activities. Backs the outbox collection and gives Update/Delete
a stable object to reference.

Column Type Notes
entity_id INT PK AI
activity_uri VARCHAR(255) UNIQUE stable AP id of the activity
store_id INT emitter store view
actor_kind ENUM(blog,catalog) which sibling actor emitted it
type ENUM(Create,Update,Delete,Accept)
object_id INT NULL → activitypub_object NULL for Accept
object_uri VARCHAR(255) referenced object
payload LONGTEXT (JSON) full JSON-LD as published (served verbatim)
published_at DATETIME

Index: (store_id,actor_kind,published_at).

A.4 activitypub_follower

Remote actors following our actors. A follower subscribes to a specific sibling actor (a human
follows blog, the aggregator follows catalog) - so the row records which.

Column Type Notes
entity_id INT PK AI
store_id INT the store view being followed
actor_kind ENUM(blog,catalog) which sibling actor is followed
follower_uri VARCHAR(255) remote actor id
inbox_uri VARCHAR(255) personal inbox
shared_inbox_uri VARCHAR(255) NULL for fan-out dedup
follow_activity_uri VARCHAR(255) for Undo matching / Accept reply
state ENUM(pending,accepted) (auto-accept vs moderated - §12.3)
created_at DATETIME

Unique: (store_id,actor_kind,follower_uri).

A.5 activitypub_delivery

Outbound delivery queue - one row per (activity, target inbox), deduped by shared inbox so a
thousand followers on one Mastodon server = one delivery.

Column Type Notes
entity_id INT PK AI
activity_id INT → activitypub_activity
target_inbox VARCHAR(255) sharedInbox preferred
status ENUM(pending,delivered,failed,dead)
attempts SMALLINT
next_attempt_at DATETIME backoff schedule
last_error VARCHAR(512) NULL
created_at / updated_at DATETIME

Index: (status,next_attempt_at) - the worker's hot query.
Backoff (Mastodon-like): +5m, +30m, +2h, +12h, +2d… → dead after ~7 days.
Worker runs via #[CronJob] (§13 delivery mechanics).

A.6 activitypub_remote_key

Cache of remote actors' public keys, populated on incoming Follow/Undo to verify signatures
(§11). Also learns the remote inbox for the Accept reply.

Column Type Notes
entity_id INT PK AI
actor_uri VARCHAR(255) UNIQUE
key_id VARCHAR(255)
public_key TEXT PEM
inbox_uri VARCHAR(255) NULL for Accept delivery
shared_inbox_uri VARCHAR(255) NULL
fetched_at / expires_at DATETIME TTL + key-rotation refresh

A.7 activitypub_engagement (optional)

Admin-only marketing counters (§10). No text, no PII. Only created if the engagement feature ships.

Column Type Notes
entity_id INT PK AI
object_id INT UNIQUE → activitypub_object
likes INT
announces INT
updated_at DATETIME

A.8 Federation opt-in (not a table)

activitypub_federate is a boolean product attribute (EAV), consulted only when
activitypub/catalog/federation_mode = per_product
(§12). In all mode every visible+active
product federates and the attribute is ignored. No dedicated table.

A.9 What is deliberately absent

No remote_object / ingested-catalog tables, no aggregated-marketplace tables, no order/
transaction tables - all out of scope per §2 (publisher-only, link-out payments). If Maho↔Maho
or on-AP transactions ever return, they add tables here; nothing above changes.


Appendix B - Configuration (system.xml / core_config_data)

Settings live in core_config_data via system.xml; operational data stays in the Appendix A
tables
(the "config = few, scoped, cached; never operational/high-write data" rule). One
activitypub section, ACL resource activitypub/config.

B.1 Config paths

Path Scope Type / backend Default Notes
activitypub/general/enabled default→website→store yesno no master switch
activitypub/general/shared_inbox_enabled default yesno yes expose endpoints.sharedInbox (E.4)
activitypub/account/public_key / private_key website encrypted, programmatic - written on enable; not admin-edited
activitypub/blog/enabled store view yesno no turns the blog actor on
activitypub/blog/public_key / private_key store view encrypted, programmatic -
activitypub/catalog/enabled store view yesno no turns the catalog actor on
activitypub/catalog/public_key / private_key store view encrypted, programmatic -
activitypub/catalog/federation_mode store view select all/per_product per_product all = whole catalog; per_product = activitypub_federate attribute governs (§12)
activitypub/catalog/default_federate store view yesno no default value of the product federate attribute (used only when mode = per_product)
activitypub/publish/debounce_window default→website seconds 3600 (1h) coalescer wait (Appendix D.1)
activitypub/publish/price_threshold_pct default→website decimal 10 min % price change to emit Update
activitypub/follow/auto_accept default→website yesno yes else → moderation queue
activitypub/aggregator/announce_target default→website text (actor URI) empty hub to opt into - store announces/follows it (§12)
activitypub/security/instance_denylist default textarea (hosts) empty skip delivery + inbox (blocks unwanted inbound follows)
activitypub/security/instance_allowlist default textarea (hosts) empty optional stricter mode - if set, only these may follow
activitypub/security/inbox_rate_limit_max default int 60 reuse Mage::helper('core')->rateLimiter()
activitypub/security/inbox_rate_limit_window default seconds 60
activitypub/engagement/enabled default→website yesno no admin-only like/boost counters (§10)

B.2 Keypairs are programmatic, not admin-edited

The *_key paths live in core_config_data but are not rendered as editable system.xml fields.
They are written (backend model encrypted) when an actor is first enabled - flipping */enabled
to yes generates the RSA-2048 pair if absent (a backend model / observer on the toggle). The admin
sees only a read-only key fingerprint + a "Regenerate keys" button per actor, never the PEM. This
is the one case where a core_config_data entry has no system.xml input.

B.3 Admin UI beyond system.xml

  • Federation status per store view: which actors are enabled, their handles, follower counts, key
    fingerprint + regenerate action.
  • Engagement grid (only if engagement/enabled): per-product like/boost counts (§10) - no text, no PII.
  • Moderation queue (only if follow/auto_accept = no): pending Follows to approve/reject (§12.3).
  • Followers list (read-only): who follows each actor.

ACL: admin/system/config/activitypub for the config section; activitypub/manage for the custom
admin pages above.


Appendix C - HTTP Signatures & crypto

C.1 Which spec: draft-cavage (mandatory baseline)

The Fediverse runs on draft-cavage-http-signatures-12, not RFC 9421. Mastodon and effectively
every AP implementation sign with the cavage Signature: header. RFC 9421 is the standardized
successor but the Fediverse has not migrated. To interoperate with Mastodon today we must
implement cavage. Design the sign/verify layer behind an interface so RFC 9421 can be added later
(the aggregator, which we control, can accept either), but ship cavage.

  • Algorithm: RSA-2048, rsa-sha256 (PKCS#1 v1.5). Not Ed25519 - cavage/Mastodon expects RSA;
    Ed25519 exists in FEPs but isn't universally accepted.
  • No LD-Signatures / object integrity proofs (FEP-8b32) in v1. Those sign the JSON-LD body so it
    survives forwarding/relaying. We deliver directly and the aggregator fetches from origin,
    so transport-level HTTP signatures suffice. Add LD-sigs later only if boosting/relaying matters.

Two distinct crypto operations - keep them separate: HTTP Signatures (per-request transport
auth, this appendix) vs LD-Signatures (body integrity, deferred).

C.2 Outbound signing (the hot path - we are a publisher)

For every POST to a remote inbox:

  1. Digest: SHA-256= + base64(sha256(raw body))
  2. Build the signing string from these headers, in order:
    (request-target): post /inbox · host: {remote host} · date: {RFC 7231 date} · digest: {above}
  3. Sign the signing string with the actor's private key (read from encrypted config, A.1) via
    openssl_sign(..., OPENSSL_ALGO_SHA256).
  4. Emit headers:
    • Signature: keyId="{actor_uri}#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="{base64}"
    • plus Date, Host, Digest, Content-Type: application/activity+json
  5. Transport = Symfony HttpClient (already in Maho), from the delivery worker.

C.3 Inbound verification (Follow / Undo only - §11)

  1. Parse the Signature header → keyId, headers list, signature.
  2. Resolve the public key: fetch the actor doc at keyId (fragment stripped) →
    publicKey.publicKeyPem; cache in activitypub_remote_key (A.6) with TTL.
  3. Reject on stale Date - require it present and within a clock-skew window (±12h for
    Mastodon compat; may tighten). This is the replay defence - cavage has no nonce.
  4. Verify Digest matches sha256 of the received body.
  5. Reconstruct the signing string from the request's actual headers (those named in headers=) and
    openssl_verify(...) against the cached public key.
  6. Success → act (register/remove follower, reply Accept). Any failure → 401.
  7. Key rotation: on a verify failure with a cached key, refetch the key once before
    rejecting (handles the remote having rotated).

C.4 SSRF guard on key fetch (critical)

keyId is attacker-controlled input. Before fetching it: require https, resolve and reject
private/loopback/link-local IP ranges, cap redirects, cap response size, set a short timeout. Reuse
this same guard for any outbound fetch of remote objects. Without it, the inbox is an SSRF vector.

C.5 Our key lifecycle

  • Generation: RSA-2048 via ext-openssl on first channel enable → write PEM pair to encrypted
    config (A.1). publicKeyPem is exposed in the actor document.
  • Rotation: generate a new pair, overwrite config. Remotes fetch our key fresh on next verify,
    so propagation is a brief lag; no coordination needed (publisher side).

C.6 Library vs hand-rolled: hand-roll the signature layer

There is no PHP AP-signatures library as solid as a mainstream OAuth lib. The cavage layer is
small (~100 lines over openssl_sign/openssl_verify, both ext-openssl stdlib). The risk is
not the crypto calls - it's matching Mastodon's exact signing-string construction, header casing,
and digest format, which a half-implemented dependency obscures. Decision:

  • Hand-roll sign/verify behind a SignatureService interface, fully unit-tested, and
    integration-tested against a real Mastodon instance.
  • Optionally use landrok/activitypub for JSON-LD/vocabulary helpers only - not for signatures.
  • HTTP transport = Symfony HttpClient (already present).

Appendix D - Delivery pipeline

Two stages: (1) change detection + coalescing turns product events into at most one activity per
object per window; (2) fan-out + a retrying worker delivers each activity to every follower's
inbox. Fan-out is on write (publisher with a bounded follower set).

D.1 Stage 1 - change detection & coalescing (the commerce-specific bit, §5)

A product save must not immediately emit an activity. Note Update is a silent edit (no
re-notification - see §5), so the concern is not notification spam but delivery load and
aggregator reprocessing
from price/stock churn.

  • Observer (catalog_product_save_after + stock/price events; and blog_post save for the blog
    stream): resolve the affected store views where the entity is federated, and for each
    (store_id, object_type, source_entity_id) set activitypub_object.is_dirty = 1, dirtied_at = now().
    Cheap, no activity yet. Create the row on first federation. Blog posts flow through the same
    dirty→coalesce path
    but skip the price/threshold logic (Create on first publish, Update on edit).
  • Coalescer cron (#[CronJob], every minute): select objects where
    is_dirty = 1 AND dirtied_at <= now() − debounce_window. Waiting for the window to elapse is what
    collapses rapid successive edits into one. For each:
    1. Recompute the object representation; compare to content_hash, last_price, last_availability.
    2. Emit an activity only if a federated-relevant change remains:
      • content changed (content_hash differs) → Update
      • availability flipped (InStock↔OutOfStock) → Update (always material)
      • price changed by > threshold % (config §12.4) → Update; sub-threshold → no activity
      • never-published-before & now publishable → Create
      • state → retiredDelete (Tombstone), supersedes any pending Update
    3. On emit: write the activitypub_activity row, refresh content_hash/last_*/last_published_at,
      then fan out (D.2). Always clear is_dirty (even when no activity - avoids empty Updates).

debounce_window and price_threshold_pct come from config (§12.4). Both stages are per store view.

D.2 Fan-out (on write)

When an activity is created, enumerate activitypub_follower for that store_id, dedup by
shared_inbox_uri
(fallback inbox_uri), and batch-insert one activitypub_delivery row per
distinct target inbox (status = pending, next_attempt_at = now()). A thousand followers on one
Mastodon server ⇒ one delivery. The Accept reply to a Follow is just a single-target delivery.

D.3 Stage 2 - delivery worker

#[CronJob] every minute; may loop up to ~55s for throughput (no daemon infra needed).

  • Claim batch: WHERE status='pending' AND next_attempt_at <= now() ORDER BY next_attempt_at LIMIT N (index (status, next_attempt_at)). Mark in-flight to avoid double-send across overlapping
    runs.
  • Send: signed POST per Appendix C, concurrently via Symfony HttpClient multiplexing (e.g. 20-50
    in flight). Skip hosts on the config denylist (§12).
  • Result handling:
    Response Action
    2xx delivered
    410 Gone / 404 (actor deleted) dead + remove the follower
    401/403/other 4xx (not 429) failed (permanent - bad signature/refused); do not retry
    429 / 5xx / network / timeout retriable: attempts++, set next_attempt_at per backoff, back to pending
  • Backoff: exponential with jitter - ~+5m, +30m, +2h, +12h, +2d - give up (dead) after ~7 days
    / ~8-10 attempts (Mastodon-like).
  • Rotation edge: a 401 that might be our own stale key vs theirs is treated as permanent here;
    remote-key rotation on inbound verify is handled in C.3.

D.4 No per-follow backfill

A new follower starts receiving posts from the follow point on - standard AP, no historical replay.
Historical catalog is fetched by the aggregator from the paginated catalog collection over AP
(§8); the blog's history from the blog outbox. So FollowAccept → live delta only.

D.5 Housekeeping

  • Prune activitypub_delivery rows in delivered/dead older than N days (cron).
  • Retain activitypub_activity enough to serve the outbox collection; cap/prune very old.
  • Optional circuit breaker: track per-host consecutive failures and globally back off a failing
    domain (Mastodon-style availability tracking). Not required for v1 - per-row backoff suffices.

Appendix E - Endpoints & routing

All endpoints are frontend-area controllers in Maho\ActivityPub, wired with #[Route]
attributes (no XML). URIs are built from immutable numeric ids (website_id, store_id,
source_entity_id) so they satisfy the §4.3 identity invariant regardless of domain routing or
slug/code changes. {kind}blog | catalog is one param on shared collection actions.

E.1 Route table

Purpose Method #[Route] path Returns
WebFinger GET /.well-known/webfinger (?resource=acct:{user}@{host}) JRD (application/jrd+json)
NodeInfo (optional) GET /.well-known/nodeinfo discovery doc
Account actor GET /activitypub/account/{websiteId} Actor Organization
Blog actor GET /activitypub/blog/{storeId} Actor Service
Catalog actor GET /activitypub/catalog/{storeId} Actor Service
Inbox (per actor) POST /activitypub/{kind}/{storeId}/inbox 202 Accepted
Shared inbox (optional) POST /activitypub/inbox 202 Accepted
Outbox GET /activitypub/{kind}/{storeId}/outbox OrderedCollection (paged)
Followers GET /activitypub/{kind}/{storeId}/followers OrderedCollection (paged)
Following GET /activitypub/{kind}/{storeId}/following OrderedCollection
Featured GET /activitypub/{kind}/{storeId}/featured OrderedCollection
Catalog collection GET /activitypub/catalog/{storeId}/catalog OrderedCollection (paged) - backfill (§8)
Article object GET /activitypub/blog/{storeId}/article/{id} Article
Offer object GET /activitypub/catalog/{storeId}/offer/{id} Offer
Activity GET /activitypub/activity/{id} the stored activity

requirements: all id params \d+; kindblog|catalog. methods as above (inbox POST-only;
everything else GET-only). The AP spec's client-to-server outbox POST is not supported - we are
a publisher, activities originate from Maho events, not from AP clients.

E.2 Content negotiation

Actor and object URLs are dual-purpose (same URL, human page vs AP document):

  • Accept: application/activity+json (or application/ld+json) → serve the AP JSON-LD, header
    Content-Type: application/activity+json.
  • Accept: text/html (a browser) → 303 See Other to the real storefront page, resolved at
    request time (so a changed slug still redirects correctly, while the numeric AP id stays stable):
    • Offer object → the product PDP · Article object → the blog post · blog actor → the store's blog
      index · account actor → the website home. This is the same URL as the object's url field (§4.2).
  • Emit Vary: Accept so caches don't cross the two representations.

Collections (outbox, followers, following, featured, catalog) are AP-only - no human
equivalent, so no dual-purpose: a browser is either redirected to the store home or simply served the
JSON. Mastodon behaves exactly this way for status/actor URLs.

E.3 WebFinger resolution

acct:{username}@{host} → resolve username (the derived handle, A.1) + host → the store view and
actor kind, then return a JRD whose links[rel="self", type="application/activity+json"] is the actor
URI. All three kinds are resolvable, but only the blog handle is advertised to humans; the catalog
handle exists for completeness (it's normally reached via the commerce:catalog link in the blog
actor document, §3).

E.4 Actor document shape

Each actor document carries: id, type, preferredUsername, name, inbox, outbox,
followers, following, publicKey { id: {keyId}, owner, publicKeyPem }, and (blog/catalog)
attributedTo → the account actor. Optional endpoints.sharedInbox when the shared inbox is enabled.
The blog actor additionally exposes commerce:catalog → the catalog actor URI, which is how the
aggregator discovers the catalog (§3).

E.5 Pagination

Collections are OrderedCollection with totalItems and a first page link; pages are
OrderedCollectionPage (?page=N) with next/prev/partOf. Applies to outbox, followers,
featured, and especially the catalog collection (cold-start over thousands of SKUs, §8).

E.6 Store resolution & security

The controller trusts the path {storeId}/{websiteId}, not ambient domain-based store
resolution, so URIs are deterministic. It must: reject ids for non-existent scopes; return 404 when
that actor kind is disabled in config (activitypub/{kind}/enabled, A.1); and apply the inbox rate
limiter and SSRF/size guards (Appendix C.4, §13 hardening) on the POST inbox routes.


Appendix F - Content mapping (builders)

Two builders produce the JSON-LD objects of §4. Both must load the entity in the target store
view's context
(store emulation) so every localized value - name, description, price, currency,
URL - is the store-view value, not the admin/default. Build once per (store_id, entity); the result
is what content_hash (A.2) is computed over.

F.1 blog_post_entityArticle

AP property Source Notes
id /activitypub/blog/{store_id}/article/{entity_id} stable, numeric (§4.3)
type "Article"
attributedTo blog actor URI
name title (store-view)
content content (store-view) sanitized HTML (F.3)
summary excerpt / meta_description optional
url resolved blog-post URL (store-view slug) absolute (F.3)
published publish_date (+ created_at time) UTC ISO-8601 (F.3)
updated updated_at on edits
attachment featured-image EAV attr → Image { url, mediaType } absolute media URL
tag blog categories/tags → Hashtag { name: "#…" }
to / cc …#Public + the blog followers collection public post

A new article is a real Create (§5) - desirable timeline post.

F.2 catalog_productOffer

AP property Source Notes
id /activitypub/catalog/{store_id}/offer/{entity_id} stable, numeric (§4.3)
type "Offer"
attributedTo catalog actor URI
name product name (store-view)
summary short_description (store-view) sanitized, may truncate
url product PDP URL (store-view slug) absolute; also the 303 target (E.2)
attachment base image + gallery (visible only) → Image[] absolute, sized rendition; cap count
schema:price final display price, guest customer group tax treatment = decision (F.3)
schema:priceCurrency store currency
schema:availability stock item → InStock / OutOfStock
schema:sku sku
schema:gtin EAN/UPC attr if present optional
schema:brand manufacturer label optional
schema:aggregateRating review summary if present optional
commerce:canonicalProduct /activitypub/product/{entity_id} account-level, cross-store-view, stable (dedup)
commerce:sellerAccount account actor URI
commerce:category mapped category path / marketplace taxonomy
to / cc …#Public + the catalog followers collection

F.3 Common rules

  • Absolute URLs everywhere. Every url/Image.url is absolute against the store view's base
    URL - never relative. Media via the media base URL.
  • HTML sanitization. content/summary HTML → a safe subset (p a strong em ul ol li br img),
    strip script/style/on*/iframes. Consumers sanitize too, but we emit clean.
  • Dates. Stored UTC → UTC ISO-8601 (Y-m-d\TH:i:s\Z) for published/updated. This is a
    non-DB, API-style payload → format the DateTimeImmutable explicitly (per AGENTS.md date rules), do
    not reuse DB-bound helpers.
  • Addressing. Public objects carry https://www.w3.org/ns/activitystreams#Public in to and the
    actor's followers collection in cc.
  • Price (decided). Publish the guest customer-group final price (after catalog price rules /
    special price), in store currency, with tax exactly as the storefront displays it (the store
    view's tax-display config decides incl./excl. - §12). Multi-currency store views each publish their
    own price (separate catalog actors).
  • Language. Each actor is single-locale (per store view), so a single content suffices; set an
    inLanguage/language hint from the store view locale. No contentMap needed.
  • Store emulation. Build inside the target store view's context so all of the above resolve to
    store-view values (name, price, url, currency, tax).

Appendix G - Module skeleton

New module Maho_ActivityPub under app/code/core/Maho/ActivityPub/, declared in
app/etc/modules/Maho_ActivityPub.xml. Follows Maho conventions: Maho\ namespace, PHP 8.3+,
declare(strict_types=1) after the file docblock, SPDX headers, #[\Override] where relevant.
Depends on Mage_Core, Mage_Catalog, Maho_Blog, Mage_Adminhtml.

G.1 Directory layout

app/code/core/Maho/ActivityPub/
├── controllers/
│   ├── WebfingerController.php      # #[Route] /.well-known/webfinger, /nodeinfo
│   ├── ActorController.php          # #[Route] actor docs + outbox/followers/following/featured/catalog + object/activity
│   ├── InboxController.php          # #[Route] POST inbox (per actor + shared)
│   └── Adminhtml/ActivitypubController.php   # admin manage pages (status, followers, moderation)
├── Block/Adminhtml/                 # federation-status grid, engagement grid, moderation queue
├── Helper/Data.php                  # URI builders, handle derivation, absolute-URL helpers
├── Model/
│   ├── Actor.php                    # identity resolver: (store_id, kind) → URIs/keys from config (A.1)
│   ├── Builder/Article.php          # F.1  blog_post → Article
│   ├── Builder/Offer.php            # F.2  product → Offer
│   ├── Signature/Service.php        # Appendix C  sign / verify (cavage)
│   ├── Security/UrlGuard.php        # C.4  SSRF guard for outbound fetches
│   ├── Delivery/Coalescer.php       # D.1  dirty → activity
│   ├── Delivery/Worker.php          # D.3  queue → signed POST (Symfony HttpClient)
│   ├── Inbox/Processor.php          # §10/§11  Follow/Undo act; everything else accept-and-drop
│   ├── Webfinger.php                # E.3  handle ↔ actor resolution
│   ├── Activity.php  Follower.php  Delivery.php  RemoteKey.php  Engagement.php  Object.php
│   ├── Resource/…                   # Doctrine DBAL resource models + collections
│   ├── Observer.php                 # #[Observer] product/blog save → mark activitypub_object dirty (D.1)
│   └── Cron.php                     # #[CronJob] coalescer, delivery worker, housekeeping (D)
├── etc/
│   ├── config.xml                   # module version, model/block/helper/resource nodes, <default> config (Appendix B)
│   ├── system.xml                   # Appendix B config fields (keys excluded - B.2)
│   └── adminhtml.xml                # admin menu + ACL (activitypub/config, activitypub/manage)
├── sql/schema.php                   # declarative Doctrine DBAL tables (Appendix A) - same pattern as Maho_Blog
└── data/activitypub_setup/
    └── data-install-1.0.0.php       # `activitypub_federate` product attribute (A.8) + default config

Translations: app/locale/en_US/Maho_ActivityPub.csv.

G.2 Wiring conventions (Maho-specific)

  • Observers, cron, routes are PHP attributes, not XML. #[Maho\Config\Observer(...)] on
    Model/Observer.php methods, #[Maho\Config\CronJob(...)] on Model/Cron.php, #[Maho\Config\Route(...)]
    on controller actions (paths per Appendix E). Run composer dump-autoload after any change - routes
    compile to vendor/composer/maho_url_matcher.php etc.
  • Schema goes in the declarative sql/schema.php (Doctrine DBAL), mirroring Maho_Blog. Never edit
    it after release
    - future changes bump the module version and add data/…/upgrade-*.php (per AGENTS.md).
  • Encrypted config for keypairs uses Maho's existing crypt (backend model encrypted, B.2); key
    generation on first actor enable is a backend model / observer on the */enabled toggle.
  • Rate limiting reuses Mage::helper('core')->rateLimiter(...) on the inbox (B.1 security paths); do
    not roll a new limiter.
  • Optional dependency: landrok/activitypub (composer) for JSON-LD/vocabulary helpers only - never for
    signatures (C.6).

G.3 Versioning

config.xml<modules><Maho_ActivityPub><version>1.0.0</version> and setup resource
<resources><activitypub_setup>. Fresh install runs sql/schema.php + data-install-1.0.0.php; later
schema/data changes are additive upgrade scripts, never edits to the 1.0.0 snapshots.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions