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): title→name, content→content,
url_key→url, publish_date/created_at→published, 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).
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
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 / Undo → must 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 / tables → resolved, see Appendix A.
HTTP Signatures details → resolved, see Appendix C.
Delivery queue mechanics → resolved, see Appendix D.
Endpoints / routing → resolved, see Appendix E.
Content mapping → resolved, 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 & admin → resolved, see Appendix B.
Module skeleton → resolved, 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:
Digest: SHA-256= + base64(sha256(raw body))
- Build the signing string from these headers, in order:
(request-target): post /inbox · host: {remote host} · date: {RFC 7231 date} · digest: {above}
- Sign the signing string with the actor's private key (read from encrypted config, A.1) via
openssl_sign(..., OPENSSL_ALGO_SHA256).
- 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
- Transport = Symfony HttpClient (already in Maho), from the delivery worker.
C.3 Inbound verification (Follow / Undo only - §11)
- Parse the
Signature header → keyId, headers list, signature.
- Resolve the public key: fetch the actor doc at
keyId (fragment stripped) →
publicKey.publicKeyPem; cache in activitypub_remote_key (A.6) with TTL.
- 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.
- Verify
Digest matches sha256 of the received body.
- Reconstruct the signing string from the request's actual headers (those named in
headers=) and
openssl_verify(...) against the cached public key.
- Success → act (register/remove follower, reply
Accept). Any failure → 401.
- 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:
- Recompute the object representation; compare to
content_hash, last_price, last_availability.
- 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 →
retired → Delete (Tombstone), supersedes any pending Update
- 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 Follow → Accept → 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+; kind → blog|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_entity → Article
| 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_product → Offer
| 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.
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:
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).
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
there is no catalog ingest on the Maho side: no aggregated marketplace view inside Maho, no
storage of remote objects, no bidirectional catalog sync.
(see §9). No escrow, no distributed transaction, no shared inventory.
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:
OrganizationattributedTotargetService/PersonArticle@negozio_de@domainto thisServiceOfferHow each party reaches the right stream - no explicit choice, it's structural:
resolves to it. Products are never in the human timeline.
actor's document (+ the
catalogcollection, §6). A curious human who found and followed it wouldjust receive raw
Offers - harmless, simply not meant for them.attributedTo→ the account (website), so an aggregator collapses thelanguage/stream variants of one seller into a single identity (PeerTube/Lemmy "account + channels").
store-view-scoped in Maho; users follow in their language.
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:
Articleis thehuman timeline content;
Offeris clean structured data for the aggregator.4.1 Blog post →
Article(blog actor → humans)Maps 1:1 from
blog_post_entity(EAV, store-scoped):title→name,content→content,url_key→url,publish_date/created_at→published, image attribute→attachment,categories/tags→
Tag,store_id→the blog actor. This is genuine social content, soCreate= areal 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.
@contextreuses schema.org where it exists(
Offer,price,priceCurrency,availability,sku,gtin,brand,Rating) and mints newterms only for the gaps in a custom open context
…/ns/commerce/v1:commerce:canonicalProduct- stable cross-language product URI (dedup collant)InStock/OutOfStock(lead time, backorder){ "@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:
id(object_uri) is stable for the object's entire federated life and derivedonly from immutable keys -
store_id+ entityentity_id(product or blog post). Never fromslug/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.)
activitypub_objectrow is created) and never regenerated.id, published asUpdate→ Mastodon edits in place(no new timeline entry, no notification); the aggregator updates the existing record.
commerce:canonicalProductis likewise stable, so language variants stay grouped.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
idis already known is an edit, not an insert. Group language variantsby
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):
is_active)CreateArticleUpdateArticleDeleteTombstoneCatalog actor (→ aggregator; silent, no timeline noise):
CreateOfferUpdateOfferUpdateOfferUpdateOfferschema:availabilityInStock/OutOfStock - object stays in feedsUpdateOffer(OutOfStock/hidden)DeleteTombstoneBoth actors answer an incoming
FollowwithAccept(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, nore-notification, at most an "edited" marker.
Createis meant to be a new post - that's the whole point of the editorial stream.Createfires once at first federation; every later change is anUpdateon thesame 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
Updateis a silent edit, debounce isnot 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) - isa 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:
Update+ availability, notDelete.Deleteonly for physical deleteor 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".
Updates intoa 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.
federate(default from config) plus aper-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).outboxofArticleactivities; optionalfeatured(pinned articles).outboxofOfferactivities plus a paginatedcatalogcollection(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 optionalaccelerator, never the source of truth.
7. Topology
Publisher-only, two streams from two sibling actors per store view:
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.
catalogCollection (§6). Primary, mandatory mechanism;any AP-only consumer cold-starts by reading it. Keeps the system self-describing over AP.
Create/Update/Delete) + delivery carries only changes,plus the social graph (
Follow/Announce).catalogCollection (backfill) →Followfor the delta.via
Maho\ApiPlatformfor a faster cold start. Pure optimization - freshness and backfill arecomplete over AP without it.
9. Payments
Link-out to the seller's Maho checkout (GNU Taler / flohmarkt model). The federated object's
urlpoints to the seller's PDP/checkout; the order is a normal Maho order on the sellerinstance. 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:202 Accepted(not an error) even when dropping - an erroring inbox makes remoteinstances retry or mark us dead.
repliescollection exposed. TheFediverse reply still lives on the sender's instance.
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/Undo→ must verify. An unsigned/forgedFollowlets an attacker subscribe avictim 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 dropunverified.
12. Product decisions
Decided:
activitypub/follow/auto_accept = yes. A moderation queuestays an optional, off-by-default feature (§10 admin UI).
activitypub/catalog/federation_mode, store-view scope):all- every visible + active product federates (zero setup, max reach)per_product- only products with theactivitypub_federateattribute on (curated)Default
per_product. (A category-level override is a deferred nicety, not v1.)config (incl./excl.); multi-currency store views each publish their own price (separate catalog
actors). No separate incl/excl toggle.
debounce_window = 3600(1h),price_threshold_pct = 10.activitypub/aggregator/announce_targetand thestore announces/follows that hub - an explicit, consent-friendly opt-in.
Followthe catalogactor; because Follow is auto-accept this "just works", gated by the instance allow/deny-list
(
security/instance_denylist; optional strictersecurity/instance_allowlist).Out of scope (not a Maho decision):
Announcefeed vs a search site over ingested data; affects onlywhether Maho must support being boosted, which it already does (inbox accepts
Announce, §10).13. OPEN - implementation spec not yet written
Data model / tables→ resolved, see Appendix A.HTTP Signatures details→ resolved, see Appendix C.Delivery queue mechanics→ resolved, see Appendix D.Endpoints / routing→ resolved, see Appendix E.Content mapping→ resolved, see Appendix F.(reuse
Mage::helper('core')->rateLimiter()), object size caps, instance allow/deny lists.Config & admin→ resolved, see Appendix B.Module skeleton→ resolved, see Appendix G.14. Effort (indicative)
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 prefixedactivitypub_, 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:
blogandcatalog(each perstore view
store_id) andaccount(per websitewebsite_id). Each is a Maho scope + a keypair;almost everything else is derived from existing store config:
base_url+ fixed AP path + scope code + actor kindkey_id{actor_uri}#main-keyattributedTopreferredUsernameactivitypub/blog/enabled,activitypub/catalog/enabled(store-view scope)The keypair is the only irreducible state, and it is per actor (blog and catalog each sign
their own deliveries). Store in
core_config_datausing theencryptedbackend model - thesame 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 scopeactivitypub/catalog/public_key/activitypub/catalog/private_key(encrypted), store-view scopeactivitypub/account/public_key/activitypub/account/private_key(encrypted), website scopeOperational tables below therefore key on
store_id+actor_kind(the natural scope every rowalready 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_dataare unwanted, a minimalactivitypub_key (scope, scope_id, public_key, private_key)table holds only the keypairs whileeverything else stays derived/config. Either way there is no full "actor" table.
A.2
activitypub_objectCurrent published representation of a federated entity - one row per (store view, actor kind,
entity), covering both products (
Offer, catalog actor) and blog posts (Article, blogactor). Holds exactly the fields needed for change detection / debounce so we don't re-emit
Updatenoise. Product-only fields are NULL for articles.entity_idstore_idobject_typeproduct,article)source_entity_idcatalog_product_entityorblog_post_entityobject_uricanonical_product_uricontent_hashlast_pricelast_availabilitylast_published_atis_dirtydirtied_atstateactive,out_of_stock,retired)created_at/updated_atUnique: (
store_id,object_type,source_entity_id). Index: (canonical_product_uri).A.3
activitypub_activityAppend-only log of emitted activities. Backs the
outboxcollection and givesUpdate/Deletea stable object to reference.
entity_idactivity_uristore_idactor_kindblog,catalog)typeCreate,Update,Delete,Accept)object_idactivitypub_objectobject_uripayloadpublished_atIndex: (
store_id,actor_kind,published_at).A.4
activitypub_followerRemote actors following our actors. A follower subscribes to a specific sibling actor (a human
follows
blog, the aggregator followscatalog) - so the row records which.entity_idstore_idactor_kindblog,catalog)follower_uriinbox_urishared_inbox_urifollow_activity_uristatepending,accepted)created_atUnique: (
store_id,actor_kind,follower_uri).A.5
activitypub_deliveryOutbound delivery queue - one row per (activity, target inbox), deduped by shared inbox so a
thousand followers on one Mastodon server = one delivery.
entity_idactivity_idactivitypub_activitytarget_inboxstatuspending,delivered,failed,dead)attemptsnext_attempt_atlast_errorcreated_at/updated_atIndex: (
status,next_attempt_at) - the worker's hot query.Backoff (Mastodon-like): +5m, +30m, +2h, +12h, +2d… →
deadafter ~7 days.Worker runs via
#[CronJob](§13 delivery mechanics).A.6
activitypub_remote_keyCache of remote actors' public keys, populated on incoming
Follow/Undoto verify signatures(§11). Also learns the remote inbox for the
Acceptreply.entity_idactor_urikey_idpublic_keyinbox_urishared_inbox_urifetched_at/expires_atA.7
activitypub_engagement(optional)Admin-only marketing counters (§10). No text, no PII. Only created if the engagement feature ships.
entity_idobject_idactivitypub_objectlikesannouncesupdated_atA.8 Federation opt-in (not a table)
activitypub_federateis a boolean product attribute (EAV), consulted only whenactivitypub/catalog/federation_mode = per_product(§12). Inallmode every visible+activeproduct 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_dataviasystem.xml; operational data stays in the Appendix Atables (the "config = few, scoped, cached; never operational/high-write data" rule). One
activitypubsection, ACL resourceactivitypub/config.B.1 Config paths
activitypub/general/enablednoactivitypub/general/shared_inbox_enabledyesendpoints.sharedInbox(E.4)activitypub/account/public_key/private_keyactivitypub/blog/enablednoactivitypub/blog/public_key/private_keyactivitypub/catalog/enablednoactivitypub/catalog/public_key/private_keyactivitypub/catalog/federation_modeall/per_productper_productall= whole catalog;per_product=activitypub_federateattribute governs (§12)activitypub/catalog/default_federatenofederateattribute (used only when mode =per_product)activitypub/publish/debounce_window3600(1h)activitypub/publish/price_threshold_pct10Updateactivitypub/follow/auto_acceptyesactivitypub/aggregator/announce_targetactivitypub/security/instance_denylistactivitypub/security/instance_allowlistactivitypub/security/inbox_rate_limit_max60Mage::helper('core')->rateLimiter()activitypub/security/inbox_rate_limit_window60activitypub/engagement/enablednoB.2 Keypairs are programmatic, not admin-edited
The
*_keypaths live incore_config_databut are not rendered as editablesystem.xmlfields.They are written (backend model
encrypted) when an actor is first enabled - flipping*/enabledto
yesgenerates the RSA-2048 pair if absent (a backend model / observer on the toggle). The adminsees only a read-only key fingerprint + a "Regenerate keys" button per actor, never the PEM. This
is the one case where a
core_config_dataentry has nosystem.xmlinput.B.3 Admin UI beyond system.xml
fingerprint + regenerate action.
engagement/enabled): per-product like/boost counts (§10) - no text, no PII.follow/auto_accept = no): pendingFollows to approve/reject (§12.3).ACL:
admin/system/config/activitypubfor the config section;activitypub/managefor the customadmin 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 standardizedsuccessor 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.
rsa-sha256(PKCS#1 v1.5). Not Ed25519 - cavage/Mastodon expects RSA;Ed25519 exists in FEPs but isn't universally accepted.
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:
Digest: SHA-256=+ base64(sha256(raw body))(request-target): post /inbox·host: {remote host}·date: {RFC 7231 date}·digest: {above}openssl_sign(..., OPENSSL_ALGO_SHA256).Signature: keyId="{actor_uri}#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="{base64}"Date,Host,Digest,Content-Type: application/activity+jsonC.3 Inbound verification (Follow / Undo only - §11)
Signatureheader →keyId,headerslist,signature.keyId(fragment stripped) →publicKey.publicKeyPem; cache inactivitypub_remote_key(A.6) with TTL.Date- require it present and within a clock-skew window (±12h forMastodon compat; may tighten). This is the replay defence - cavage has no nonce.
Digestmatches sha256 of the received body.headers=) andopenssl_verify(...)against the cached public key.Accept). Any failure → 401.rejecting (handles the remote having rotated).
C.4 SSRF guard on key fetch (critical)
keyIdis attacker-controlled input. Before fetching it: require https, resolve and rejectprivate/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
config (A.1).
publicKeyPemis exposed in the actor document.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 isnot the crypto calls - it's matching Mastodon's exact signing-string construction, header casing,
and digest format, which a half-implemented dependency obscures. Decision:
SignatureServiceinterface, fully unit-tested, andintegration-tested against a real Mastodon instance.
landrok/activitypubfor JSON-LD/vocabulary helpers only - not for signatures.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
Updateis a silent edit (nore-notification - see §5), so the concern is not notification spam but delivery load and
aggregator reprocessing from price/stock churn.
catalog_product_save_after+ stock/price events; andblog_postsave for the blogstream): resolve the affected store views where the entity is federated, and for each
(store_id, object_type, source_entity_id)setactivitypub_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).
#[CronJob], every minute): select objects whereis_dirty = 1 AND dirtied_at <= now() − debounce_window. Waiting for the window to elapse is whatcollapses rapid successive edits into one. For each:
content_hash,last_price,last_availability.content_hashdiffers) →UpdateUpdate(always material)Update; sub-threshold → no activityCreateretired→Delete(Tombstone), supersedes any pendingUpdateactivitypub_activityrow, refreshcontent_hash/last_*/last_published_at,then fan out (D.2). Always clear
is_dirty(even when no activity - avoids emptyUpdates).debounce_windowandprice_threshold_pctcome from config (§12.4). Both stages are per store view.D.2 Fan-out (on write)
When an activity is created, enumerate
activitypub_followerfor thatstore_id, dedup byshared_inbox_uri(fallbackinbox_uri), and batch-insert oneactivitypub_deliveryrow perdistinct target inbox (
status = pending,next_attempt_at = now()). A thousand followers on oneMastodon server ⇒ one delivery. The
Acceptreply to aFollowis 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).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 overlappingruns.
in flight). Skip hosts on the config denylist (§12).
delivereddead+ remove the followerfailed(permanent - bad signature/refused); do not retryattempts++, setnext_attempt_atper backoff, back topendingdead) after ~7 days/ ~8-10 attempts (Mastodon-like).
401that 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
catalogcollection over AP(§8); the blog's history from the blog
outbox. SoFollow→Accept→ live delta only.D.5 Housekeeping
activitypub_deliveryrows indelivered/deadolder than N days (cron).activitypub_activityenough to serve theoutboxcollection; cap/prune very old.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 orslug/code changes.
{kind}∈blog | catalogis one param on shared collection actions.E.1 Route table
#[Route]path/.well-known/webfinger(?resource=acct:{user}@{host})application/jrd+json)/.well-known/nodeinfo/activitypub/account/{websiteId}Organization/activitypub/blog/{storeId}Service/activitypub/catalog/{storeId}Service/activitypub/{kind}/{storeId}/inbox202 Accepted/activitypub/inbox202 Accepted/activitypub/{kind}/{storeId}/outboxOrderedCollection(paged)/activitypub/{kind}/{storeId}/followersOrderedCollection(paged)/activitypub/{kind}/{storeId}/followingOrderedCollection/activitypub/{kind}/{storeId}/featuredOrderedCollection/activitypub/catalog/{storeId}/catalogOrderedCollection(paged) - backfill (§8)/activitypub/blog/{storeId}/article/{id}Article/activitypub/catalog/{storeId}/offer/{id}Offer/activitypub/activity/{id}requirements: all id params\d+;kind→blog|catalog.methodsas above (inbox POST-only;everything else GET-only). The AP spec's client-to-server outbox
POSTis not supported - we area 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(orapplication/ld+json) → serve the AP JSON-LD, headerContent-Type: application/activity+json.Accept: text/html(a browser) →303 See Otherto the real storefront page, resolved atrequest time (so a changed slug still redirects correctly, while the numeric AP
idstays stable):index · account actor → the website home. This is the same URL as the object's
urlfield (§4.2).Vary: Acceptso caches don't cross the two representations.Collections (
outbox,followers,following,featured,catalog) are AP-only - no humanequivalent, 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}→ resolveusername(the derived handle, A.1) +host→ the store view andactor kind, then return a JRD whose
links[rel="self", type="application/activity+json"]is the actorURI. 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:cataloglink in the blogactor 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. Optionalendpoints.sharedInboxwhen the shared inbox is enabled.The blog actor additionally exposes
commerce:catalog→ the catalog actor URI, which is how theaggregator discovers the catalog (§3).
E.5 Pagination
Collections are
OrderedCollectionwithtotalItemsand afirstpage link; pages areOrderedCollectionPage(?page=N) withnext/prev/partOf. Applies tooutbox,followers,featured, and especially thecatalogcollection (cold-start over thousands of SKUs, §8).E.6 Store resolution & security
The controller trusts the path
{storeId}/{websiteId}, not ambient domain-based storeresolution, so URIs are deterministic. It must: reject ids for non-existent scopes; return
404whenthat actor kind is disabled in config (
activitypub/{kind}/enabled, A.1); and apply the inbox ratelimiter 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 resultis what
content_hash(A.2) is computed over.F.1
blog_post_entity→Articleid/activitypub/blog/{store_id}/article/{entity_id}type"Article"attributedTonametitle(store-view)contentcontent(store-view)summarymeta_descriptionurlpublishedpublish_date(+created_attime)updatedupdated_atattachmentImage { url, mediaType }tagHashtag { name: "#…" }to/cc…#Public+ the blogfollowerscollectionA new article is a real
Create(§5) - desirable timeline post.F.2
catalog_product→Offerid/activitypub/catalog/{store_id}/offer/{entity_id}type"Offer"attributedTonamename(store-view)summaryshort_description(store-view)url303target (E.2)attachmentImage[]schema:priceschema:priceCurrencyschema:availabilityInStock/OutOfStockschema:skuskuschema:gtinschema:brandmanufacturerlabelschema:aggregateRatingcommerce:canonicalProduct/activitypub/product/{entity_id}commerce:sellerAccountcommerce:categoryto/cc…#Public+ the catalogfollowerscollectionF.3 Common rules
url/Image.urlis absolute against the store view's baseURL - never relative. Media via the media base URL.
content/summaryHTML → a safe subset (p a strong em ul ol li br img),strip
script/style/on*/iframes. Consumers sanitize too, but we emit clean.Y-m-d\TH:i:s\Z) forpublished/updated. This is anon-DB, API-style payload → format the
DateTimeImmutableexplicitly (per AGENTS.md date rules), donot reuse DB-bound helpers.
https://www.w3.org/ns/activitystreams#Publicintoand theactor's
followerscollection incc.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).
contentsuffices; set aninLanguage/language hint from the store view locale. NocontentMapneeded.store-view values (name, price, url, currency, tax).
Appendix G - Module skeleton
New module
Maho_ActivityPubunderapp/code/core/Maho/ActivityPub/, declared inapp/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
Translations:
app/locale/en_US/Maho_ActivityPub.csv.G.2 Wiring conventions (Maho-specific)
#[Maho\Config\Observer(...)]onModel/Observer.phpmethods,#[Maho\Config\CronJob(...)]onModel/Cron.php,#[Maho\Config\Route(...)]on controller actions (paths per Appendix E). Run
composer dump-autoloadafter any change - routescompile to
vendor/composer/maho_url_matcher.phpetc.sql/schema.php(Doctrine DBAL), mirroringMaho_Blog. Never editit after release - future changes bump the module version and add
data/…/upgrade-*.php(per AGENTS.md).encrypted, B.2); keygeneration on first actor enable is a backend model / observer on the
*/enabledtoggle.Mage::helper('core')->rateLimiter(...)on the inbox (B.1 security paths); donot roll a new limiter.
landrok/activitypub(composer) for JSON-LD/vocabulary helpers only - never forsignatures (C.6).
G.3 Versioning
config.xml→<modules><Maho_ActivityPub><version>1.0.0</version>and setup resource<resources><activitypub_setup>. Fresh install runssql/schema.php+data-install-1.0.0.php; laterschema/data changes are additive upgrade scripts, never edits to the 1.0.0 snapshots.