Audlis is a first-class Fediverse citizen: external Mastodon / Pixelfed / Akkoma users can discover, follow, and interact with public Audlis content. This document covers operations, the protocol surface, and the data model. For the product feature spec see the project README; for implementation history see the git log of src/lib/ap/.
TL;DR — what got built
| Capability | Status |
|---|---|
@audlis@shop.audlis.com discoverable via webfinger | ✅ |
| Inbound Follow (auto-accept) + Undo | ✅ |
Outbound Create for new public designs | ✅ |
Outbound Create{Product} for curated POD products | ✅ |
Outbound Create{Note} for admin manual pushes | ✅ |
| Inbound Like / Announce / Undo | ✅ |
| Inbound Create{Note} replies, content-moderated, shown on PDP/design page | ✅ |
| Inbound Delete / Update of remote notes | ✅ |
| Admin: instance blocklist | ✅ |
| Admin: delivery queue monitoring + manual redeliver | ✅ |
| Admin: federation toggles in SiteSettings | ✅ |
Per-creator federation actors (@handle@shop.audlis.com) | ⏳ Phase 2 |
| Outbound follows (Audlis users follow external accounts) | ⏳ Phase 3 |
Initial deployment checklist
Run these in order the first time you enable federation on an environment.
# 1. Apply the federation schema migration.
npx wrangler d1 execute audlis-db --remote \
--file=./src/db/migrations/024_federation_phase1.sql
# 2. Generate the @audlis service actor's RSA keypair and write it to D1.
node scripts/init-ap-actor.cjs --remote
# 3. Redeploy the site + cron worker.
npm run build && npm run deploy
npm run deploy:cron
# 4. Verify discovery works.
curl -H 'Accept: application/jrd+json' \
'https://shop.audlis.com/.well-known/webfinger?resource=acct:audlis@shop.audlis.com'
curl -H 'Accept: application/activity+json' \
https://shop.audlis.com/ap/actor/audlis
# 5. From a Mastodon account on another instance, search for @audlis@shop.audlis.com
# and click Follow. Watch `npx wrangler tail` for `[ap.inbox] Follow from ...`.HTTP endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /.well-known/webfinger?resource=acct:<h>@<d> | Actor discovery |
| GET | /ap/actor/<id> | Actor AS2 document (inbox / outbox / publicKey) |
| POST | /ap/actor/<id>/inbox | Inbound activity delivery (signed) |
| GET | /ap/actor/<id>/inbox | Empty OrderedCollection (spec compliance) |
| GET | /ap/actor/<id>/outbox?page=true | Public activity stream |
| GET | /ap/actor/<id>/followers?page=true | Followers collection |
| GET | /ap/object/<type>/<id> | Single object AS2 (design / product) |
| GET | /api/federation/replies?type=<t>&id=<i> | Approved federated replies (public) |
Admin endpoints (require admin session):
| Method | Path | Purpose |
|---|---|---|
| GET | /api/admin/federation/status | Queue + follower + reply counts |
| POST | /api/admin/federation/redeliver | Reset failed deliveries to pending |
| POST | /api/admin/federation/broadcast | Manual Note push |
| GET / POST / DELETE | /api/admin/federation/blocks | Instance blocklist CRUD |
| GET / PATCH | /api/admin/federation/notes | Federated reply moderation |
Data model (migration 024)
| Table | Holds |
|---|---|
ap_actors | Local actors (Phase 1: only the service row for @audlis) |
ap_remote_actors | Cache of resolved remote actors (publicKey, inbox, etc.) |
ap_follows | Bidirectional follow graph |
ap_received_activities | Inbound idempotency log (Activity URI → processed) |
ap_delivery_queue | Outbound fan-out, polled by cron worker |
ap_object_activities | Local object ↔ Activity reverse index |
ap_notes | Federated replies, with safety state |
ap_interactions | Like / Announce records |
ap_instance_blocks | Admin-maintained instance blocklist |
Settings in app_config:
| Key | Default | Effect |
|---|---|---|
ap_follow_manual_approval | false | When true, new follows queue for admin approval |
ap_autobroadcast_designs | true | When false, design publishes don't fan out |
ap_autobroadcast_products | true | When false, mockup promotions don't fan out |
ap_service_actor_id | audlis | The service actor's id / preferred_username |
ap_service_display_name | Audlis | Display name shown on the actor document |
ap_service_summary | … | Bio string on the actor document |
Security model
- HTTP Signatures (draft-cavage-12): every inbound POST to
/ap/actor/*/inboxmust carry aSignatureheader. Verification uses RSA-PKCS#1-v1.5 / SHA-256 viacrypto.subtle. The sender's public key is fetched from their actor document (cached inap_remote_actors). On verify failure against a cached key we force-refresh once before rejecting — this absorbs key rotations. - Actor/keyId binding: the
actorfield of an inbound Activity must equal the URI of the actor that owns the signing key. A validly-signed activity claiming to be from a different actor is rejected with 401. - Digest + Date: requests with a body must carry a
Digestheader matchingSHA-256=<base64>of the body.Datemust be within ±30s of now and no older than 12h. - Idempotency: Activity URIs are recorded in
ap_received_activitiesbefore dispatch; duplicate delivery of the same activity is ack'd without re-processing. - Instance blocklist: requests from blocked domains get 403; outbound deliveries to followers on blocked domains are skipped.
- Reply moderation: every inbound Note runs through
src/lib/content-safety.ts. Matching any block/flag category puts the row instate='flagged'for admin review rather than displaying it.
Delivery semantics
The cron worker (cron-worker/index.ts) polls ap_delivery_queue once per minute and processes up to 25 rows per tick:
- Claim: atomically flip
pending→processingfor rows withnext_attempt_at <= now. - Sign: import the sending actor's private key from
ap_actors.private_key_pem, build the(request-target) host date digestsigning string, sign with RSA-PKCS#1-v1.5. - POST: deliver the activity to the target inbox with
Content-Type: application/activity+json. Timeout 10s. - Outcome:
- 2xx →
state='sent' - 5xx / 408 / 429 / network error →
attempts++,next_attempt_at = now + min(2^attempts · 60, 3600). Aftermax_attempts(5) the row flips tofailed. - other 4xx → permanent
failed(no retry).
- 2xx →
Shared inbox dedup: Mastodon exposes one sharedInbox per instance. When delivering to N followers on mastodon.social we POST once to the shared inbox. This keeps queue size proportional to the number of instances we're federating with, not the number of followers.
Key rotation
The service actor's keypair lives in ap_actors.private_key_pem / public_key_pem. To rotate:
node scripts/init-ap-actor.cjs --remote --rotateRemote peers cache our public key until their next signature-mismatch triggers a re-fetch, so rotation causes a brief (seconds-to-minutes) delivery hiccup. There is no need to coordinate — just rotate and the system self-heals.
Debugging
- Live logs:
npx wrangler tailshows[ap.inbox] <Type> from <actor>lines for every inbound activity and[federation] design|product|note ... broadcast → N queuedfor outbound. - Queue status:
curl -H 'Cookie: audlis_session=<admin>' https://shop.audlis.com/api/admin/federation/statusor open the admin dashboard. - Failed delivery: in the admin panel, find the row in the "Federation" tab and click "Redeliver", or POST
/api/admin/federation/redeliverwith{ "id": "<row id>" }. - Stuck reply: replies in
state='flagged'won't show on the PDP until an admin approves via/api/admin/federation/notesPATCH.
Roadmap
- Phase 2 — creator actors: per-user
@handle@shop.audlis.com, public/u/<handle>storefront, attribution of design Creates to the creator actor (with fallback to service actor when federation is disabled for that user). ✅ Shipped. - Phase 3 — bidirectional social: outbound follows (Audlis users follow external accounts), notification center aggregating federated interactions, visible interaction counts on design / product pages. ✅ Shipped.
Phase 3 — outbound follows + notification center
Phase 3 turns Audlis from a broadcast-only node into a participant in the social graph: federation-enabled users can follow external Fediverse accounts, see who followed / liked / boosted / replied to their work, and get notified when accounts they follow post new content.
What shipped
- Outbound follows — a creator can follow any
@handle@instancefrom the new Account → Following tab. Remote webfinger resolution (src/lib/ap/webfinger.ts) turns the typed string into a cached actor;followRemoteActor(src/lib/ap/follow.ts) writes the outbound edge and enqueues a signedFollowactivity to the remote inbox. InboundAccept/Reject(already handled by Phase 1'shandleAccept) flips the edge state. Unfollow sends anUndo { Follow }. - Notification center — the new Account → Notifications tab plus a header bell badge surface four event types:
follow— someone followed the creator's actorlike/announce— someone liked / boosted the creator's designreply— someone replied (only on approved replies; flagged ones stay quiet to avoid notifying about violative content)new_work— an actor the creator follows posted a new Create
- Subscription via outbox polling — the cron worker's new hourly
processApOutboxPulltask fetches each followed actor's outbox, finds Create activities newer than the per-actor cursor, and emits anew_worknotification to every Audlis user following them. 1-hour minimum cadence per actor; 3 consecutive errors trigger 24h backoff. - Per-design interaction counts —
/api/discover/[id]now returnslikeCountandboostCount; DesignDetail renders them inline next to views/uses when nonzero.
New endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | /api/account/resolve-actor {q} | Resolve @h@d or URI to actor preview |
| GET | /api/account/follows?limit=&offset= | List outbound follows |
| POST | /api/account/follows {remoteActorId} | Follow |
| DELETE | /api/account/follows {remoteActorId} | Unfollow |
| GET | /api/account/notifications?limit=&offset= | Feed; first GET marks all read |
| GET | /api/account/notifications/unread-count | Bell badge poller |
All require a signed-in user with ap_enabled=1 (Phase 2 opt-in).
New tables (migration 026)
| Table | Purpose |
|---|---|
ap_notifications | Unified, user-keyed notification feed with seen_at |
ap_outbox_cursors | Per-remote-actor pull cursor + error backoff |
Also ALTER TABLE ap_remote_actors ADD COLUMN outbox_url (populated when the actor document is cached).
Unread semantics
Opening the Notifications tab (GET /api/account/notifications with no ?peek=1) marks every unread notification as seen, server-side, before returning the page. The header bell polls /unread-count (which does NOT mutate state) every 60s, so the badge clears within a minute of the user opening the tab.
Subscription notifications — why polling, not push
ActivityPub has no standard "subscribe to my outbox" S2S mechanism. The only universally-supported way to learn about a remote actor's new posts is to fetch their outbox. Phase 3 polls hourly per actor with a 1-hour minimum cadence; freshness within an hour is appropriate for a discovery feature and keeps our load on remote instances minimal.
