Skip to content

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

CapabilityStatus
@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.

sh
# 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

MethodPathPurpose
GET/.well-known/webfinger?resource=acct:<h>@<d>Actor discovery
GET/ap/actor/<id>Actor AS2 document (inbox / outbox / publicKey)
POST/ap/actor/<id>/inboxInbound activity delivery (signed)
GET/ap/actor/<id>/inboxEmpty OrderedCollection (spec compliance)
GET/ap/actor/<id>/outbox?page=truePublic activity stream
GET/ap/actor/<id>/followers?page=trueFollowers 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):

MethodPathPurpose
GET/api/admin/federation/statusQueue + follower + reply counts
POST/api/admin/federation/redeliverReset failed deliveries to pending
POST/api/admin/federation/broadcastManual Note push
GET / POST / DELETE/api/admin/federation/blocksInstance blocklist CRUD
GET / PATCH/api/admin/federation/notesFederated reply moderation

Data model (migration 024)

TableHolds
ap_actorsLocal actors (Phase 1: only the service row for @audlis)
ap_remote_actorsCache of resolved remote actors (publicKey, inbox, etc.)
ap_followsBidirectional follow graph
ap_received_activitiesInbound idempotency log (Activity URI → processed)
ap_delivery_queueOutbound fan-out, polled by cron worker
ap_object_activitiesLocal object ↔ Activity reverse index
ap_notesFederated replies, with safety state
ap_interactionsLike / Announce records
ap_instance_blocksAdmin-maintained instance blocklist

Settings in app_config:

KeyDefaultEffect
ap_follow_manual_approvalfalseWhen true, new follows queue for admin approval
ap_autobroadcast_designstrueWhen false, design publishes don't fan out
ap_autobroadcast_productstrueWhen false, mockup promotions don't fan out
ap_service_actor_idaudlisThe service actor's id / preferred_username
ap_service_display_nameAudlisDisplay name shown on the actor document
ap_service_summaryBio string on the actor document

Security model

  • HTTP Signatures (draft-cavage-12): every inbound POST to /ap/actor/*/inbox must carry a Signature header. Verification uses RSA-PKCS#1-v1.5 / SHA-256 via crypto.subtle. The sender's public key is fetched from their actor document (cached in ap_remote_actors). On verify failure against a cached key we force-refresh once before rejecting — this absorbs key rotations.
  • Actor/keyId binding: the actor field 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 Digest header matching SHA-256=<base64> of the body. Date must be within ±30s of now and no older than 12h.
  • Idempotency: Activity URIs are recorded in ap_received_activities before 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 in state='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:

  1. Claim: atomically flip pendingprocessing for rows with next_attempt_at <= now.
  2. Sign: import the sending actor's private key from ap_actors.private_key_pem, build the (request-target) host date digest signing string, sign with RSA-PKCS#1-v1.5.
  3. POST: deliver the activity to the target inbox with Content-Type: application/activity+json. Timeout 10s.
  4. Outcome:
    • 2xx → state='sent'
    • 5xx / 408 / 429 / network error → attempts++, next_attempt_at = now + min(2^attempts · 60, 3600). After max_attempts (5) the row flips to failed.
    • other 4xx → permanent failed (no retry).

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:

sh
node scripts/init-ap-actor.cjs --remote --rotate

Remote 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 tail shows [ap.inbox] <Type> from <actor> lines for every inbound activity and [federation] design|product|note ... broadcast → N queued for outbound.
  • Queue status: curl -H 'Cookie: audlis_session=<admin>' https://shop.audlis.com/api/admin/federation/status or 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/redeliver with { "id": "<row id>" }.
  • Stuck reply: replies in state='flagged' won't show on the PDP until an admin approves via /api/admin/federation/notes PATCH.

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@instance from 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 signed Follow activity to the remote inbox. Inbound Accept / Reject (already handled by Phase 1's handleAccept) flips the edge state. Unfollow sends an Undo { Follow }.
  • Notification center — the new Account → Notifications tab plus a header bell badge surface four event types:
    • follow — someone followed the creator's actor
    • like / announce — someone liked / boosted the creator's design
    • reply — 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 processApOutboxPull task fetches each followed actor's outbox, finds Create activities newer than the per-actor cursor, and emits a new_work notification 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 returns likeCount and boostCount; DesignDetail renders them inline next to views/uses when nonzero.

New endpoints

MethodPathPurpose
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-countBell badge poller

All require a signed-in user with ap_enabled=1 (Phase 2 opt-in).

New tables (migration 026)

TablePurpose
ap_notificationsUnified, user-keyed notification feed with seen_at
ap_outbox_cursorsPer-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.

Synergy Books · SYS-BOOK Series