Skip to content

feat: instance-level ActivityPub federation - #1079

Open
Flomp wants to merge 199 commits into
devfrom
feature/ap-instance-actors
Open

feat: instance-level ActivityPub federation#1079
Flomp wants to merge 199 commits into
devfrom
feature/ap-instance-actors

Conversation

@Flomp

@Flomp Flomp commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds an Application-type instance actor so each Wanderer instance has its own ActivityPub identity, independent of any user account
  • Implements a mutual Follow lifecycle between two instance actors: discover → follow → accept/reject → disconnect, with full HTTP-signature delivery and inbox handling
  • Fans out all public content (trails, comments, summit logs, lists) to connected peer instances via the instance actor's follower/following sets, without touching the existing user-level federation path
  • Adds a federation admin dashboard at localhost:8090/federation/ — a standalone SPA injected into the PocketBase admin panel via the UI extension API, matching the PocketBase visual language (IBM Plex Sans, Remix Icons, accent colour theming)
  • Fixes NodeInfo to count only local public trails (not federated mirrors), and injects the correct version from web/package.json via the SvelteKit proxy

What's new

Area Detail
Instance actor Application-type AP actor created at startup; RSA keypair generated once and stored encrypted
Follow lifecycle Outbound Follow → remote Accept/Reject → local status update; inbound Follow → admin Approve/Reject → Accept/Reject delivery
Content fanout instanceFollowerInboxes() appended to recipient list for all Create/Update/Delete activities on public content
Instance inbox POST /api/v1/activitypub/instance/inbox — HTTP-sig verified, dispatches Follow/Accept/Reject/Undo/Create/Update/Delete
Admin API POST /federation/discover, /follow, /approve/:id, /reject/:id, /disconnect/:id, GET /federation/peers
Admin UI Alpine.js SPA at GET /federation/ — discover, connect, manage peers, confirmation modals, status badges
NodeInfo fix Trail count filtered to is_local = 1; version injected from package.json in SvelteKit proxy
Re-follow after rejection Rejected follow record is cleared so admins can retry without a 409

Test plan

Instance actor

  • Fresh database: instance actor record is created automatically at startup with actor_type = instance, is_local = true, and a non-empty RSA public key
  • Restart the server: the same actor record is reused (keypair is NOT regenerated)
  • GET /api/v1/activitypub/instance returns a valid ActivityPub Application object with inbox, outbox, and publicKey

Discover

  • /federation/ → paste a valid remote Wanderer URL → "Discover" shows the remote instance domain, version, user count, and trail count
  • Paste the local instance URL → discover returns an error (self-follow guard)
  • Paste a non-Wanderer URL → discover returns an appropriate error
  • Discover button is disabled when the URL field is empty

Follow lifecycle

  • After discover, click "Connect" → a pending follow record appears in the peers table with "Awaiting approval…" and a "Cancel request" button
  • Clicking "Cancel request" opens the confirmation modal ("Cancel follow request?") and on confirm withdraws the request; the row disappears
  • On the remote instance, the inbound follow request appears as pending with Approve / Reject buttons
  • Approve → both sides show accepted; content starts flowing
  • Reject → requesting instance sees rejected; re-attempting the follow succeeds (rejected record is cleared first)
  • Disconnect (accepted peer) → opens "Disconnect instance?" modal → on confirm the row is removed from both sides

Content sync

  • Create a public trail on instance A → it appears on instance B within seconds
  • Update the trail on A → the change propagates to B
  • Delete the trail on A → it is removed from B
  • A private trail (is_public = false) is never delivered to B
  • Comments, summit logs, and lists follow the same Create/Update/Delete flow

Admin UI

  • The PocketBase admin header shows a "Federation" link that opens /federation/ in a new tab
  • Closing the tab via the "Close" button (top-right) works; it does not navigate away
  • Visiting /federation/ without a superuser session shows the login prompt card, not the peer table
  • Dark mode and non-default accent colours are picked up correctly on first load (no flash)
  • Empty state ("No peer connections yet") is centred in the table

NodeInfo

  • GET /.well-known/nodeinfo/2.1software.version matches the value in web/package.json
  • localPosts count equals the number of public trails authored on this instance only (federated trails excluded)

Christian Beutel and others added 30 commits June 22, 2026 19:25
- TestInitInstanceActorCreatesApplicationActor: verifies actor_type=Application, preferred_username=instance, is_local=true, stable IRI, PEM public key
- TestInitInstanceActorIsIdempotent: verifies single record and identical public_key after two calls
- TestInitInstanceActorNameFromOrigin: verifies www. stripped from hostname in username field
- TestInstanceActorJSONShape: verifies AP JSON map keys via buildInstanceActorJSON helper
- Tests fail to compile (RED) because InitInstanceActor and buildInstanceActorJSON are undefined
- db/migrations/1782290000_add_actor_type_to_activitypub_actors.go: adds actor_type text column to activitypub_actors (pbc_1295301207, field id text_actor_type_001), backfills existing rows to 'Person'
- db/federation/instance.go: InitInstanceActor (idempotent startup, RSA keypair, Application actor), buildInstanceActorJSON helper, InstanceActorGet handler (map-based to include manuallyApprovesFollowers)
- db/main.go: add pocketbase/federation import, register GET /activitypub/instance route, call federation.InitInstanceActor synchronously in initData before goroutine
- db/federation/instance_test.go: updated test harness to use core.NewBaseApp with programmatic collection creation and system migrations import (avoids Meilisearch dependency)
- All four RED tests now pass (GREEN)
…ce values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
app.Save(collection) is what issues the ALTER TABLE. The backfill UPDATE
must run after Save, not before, otherwise the column doesn't exist yet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…activitypub/instance

PocketBase is not publicly accessible; the SvelteKit layer serves all
/api/v1/* requests. Queries activitypub_actors directly via locals.pb,
same pattern as existing user actor routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…r docs

SvelteKit route handles the public endpoint; InstanceActorGet and
buildInstanceActorJSON are no longer needed in the Go layer.
Also removes the PocketBase route registration and TestInstanceActorJSONShape
which tested the deleted helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 plans in 2 waves covering INST-03, FLCL-01..05:
- 02-01: migration adding "rejected" to follows.status
- 02-02: instance inbox endpoint + incoming Follow -> pending
- 02-03: admin lifecycle hooks (Follow/Accept/Reject/Undo)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New migration 1782290001 appends "rejected" to follows.status allowed values
- Idempotent up-function: skips append if value already present
- Reversible down-function: filters out "rejected", restoring ["pending","accepted"]
- Follows collection id: 8obn1ukumze565i, status field id: select2063623452
- SUMMARY.md for plan 02-01
- Migration 1782290001 adds "rejected" to follows.status select field
- Schema prerequisite for FLCL-04 (Reject{Follow} lifecycle) satisfied
Christian Beutel and others added 29 commits June 27, 2026 22:39
Both the login prompt and main dashboard were visible briefly before Alpine
ran, leaking the full dashboard to unauthenticated users on slow connections.
x-cloak + an inline [x-cloak]{display:none} style rule (not Tailwind — CDN
is async) hides both sections until Alpine sets authenticated and removes the
attribute.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
x-cloak relies on an attribute-selector CSS rule that Tailwind CDN can
interfere with. Using style="display:none" on <main> guarantees it is
hidden before any script runs — Alpine's x-show overwrites the inline
display property when authenticated=true. The login prompt has no inline
style so it shows immediately as the safe default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Alpine.js v3 uses Function() constructor internally and requires
'unsafe-eval' in script-src. img-src 'none' blocked the browser's
automatic favicon request; changed to 'self'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Inline logo_text_light.svg (white fills for dark background) into the
header, replacing the plain text h1. Scaled to 40px height with a
"Federation" label separator.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Group logo+label into left div so admin link stays right-aligned.
Replace inline chevron SVG with fa-gauge-high. Add FA 6 CDN and
extend CSP with style-src/font-src for cdnjs.cloudflare.com.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…extension

Uses the experimental UIExtensions API (v0.37.0+, discussion #7612).
Embeds federation_ext/main.js which calls app.store.headerLinks.push()
with the ri-share-circle-line Remix Icon and href /federation/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Custom CSS replacing Tailwind: IBM Plex Sans, CSS vars for
  accent/surface/border/text, dark mode via html.dark class
- Accent color read from pbSettings localStorage at paint time;
  --accent-fade computed from parsed hex for focus rings
- Header bg: var(--accent); logo inner fills use .logo-accent-fill
  so mountain cutout blends into the coloured header background
- Remix Icons (ri-) replace Font Awesome throughout
- Admin email extracted from JWT payload shown in header
- PocketBase-style table: uppercase column headers, row hover,
  table-footer "Total: N" bar
- Pill badges with per-status/direction colour tokens
- Spinner variants: white (in buttons) and accent (standalone)
- Login prompt card replaces old inline prompt
- CSP updated: drop Tailwind + cdnjs; add fonts.googleapis.com,
  fonts.gstatic.com, cdn.jsdelivr.net for style/font-src

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The colspan="4" empty-state td was caught by the last-child rule
since it's both first and last child in its row. Scope the rule
with :not(.state-cell) to keep it centred.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PocketBase opens /federation/ in a new tab, so navigating back
is wrong — close the tab instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ctors

assembleActor never mapped pubActor.Type to the actor_type field,
so remote instance actors were stored with a blank actor_type.
Check for ApplicationType after the remote fetch and set it explicitly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
createOutboundFollow blocked any retry when a follow record
existed, regardless of status. A rejected record should be
removed so the admin can send a new Follow — only pending
and accepted states are genuine conflicts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a danger-themed confirmation modal (icon, domain name,
description, Cancel + Disconnect actions) matching the PocketBase
admin visual language. Escape and backdrop click also dismiss it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Outbound-pending rows now show a "Cancel request" button alongside
the waiting label. Reuses the disconnect endpoint (which sends
Undo·Follow) and the same confirmation modal, but with context-aware
title and body copy ("Cancel follow request?" / "Withdraw request").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous query counted all public trails including those
federated from remote instances. Join against activitypub_actors
and filter is_local=1 so the count reflects what this instance
produces, matching fediverse convention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…JOIN

buildNodeInfo21 now joins activitypub_actors to filter local trails,
so the test app needs that table and an author relation on trails.
Added seedLocalActor helper and wired author on seeded trail records.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Version is now injected by the SvelteKit proxy from web/package.json.
Go hardcodes "dev" as a placeholder; the proxy overwrites it before
the response reaches any client. Drop the two env-var version tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Flomp
Flomp changed the base branch from main to dev August 7, 2026 17:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant