Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Plamenu documentation

Plamenu is an experimental ActivityPub server with a web interface and a Mastodon-compatible API. Experimental releases may change database and configuration formats incompatibly.

Use documentation from the same revision or release as your server. The command-line reference is generated from the program’s help.

Use the web interface

Sign in on your server’s website. New post opens the composer; profiles and posts have menus for follow, reply, quote, mute, block, and report actions.

NavigationContents
HomePosts from accounts you follow
Live feedsLocal and federated public timelines
SearchPeople, posts, links, and hashtags known to the server
NotificationsMentions, follows, favourites, boosts, and other activity
Private mentionsConversations addressed to specific accounts
Bookmarks / FavouritesSaved posts / posts you have favourited
ListsTimelines for selected accounts you follow
GroupsCommunities and their threads
AppsWebxdc sessions
People / TrendingProfile and content discovery
SettingsProfile, preferences, security, and account management

To find a remote account or post your server has not seen, search for its full handle or public URL. Search results depend on what the remote server makes available and your server’s federation policy. Remote changes may take time to arrive.

Read posting for composer controls and account settings for privacy, filters, and exports. For sign-in problems or moderation decisions, contact your server’s operators.

You can also sign in through a Mastodon-compatible app using the server’s hosting domain. Available features depend on the app; see client connections.

Posting

Open New post to write a post, or use Reply or Quote on an existing post. The toolbar has controls for attachments, polls, scheduling, content warnings, emoji, visibility, quote permissions, text format, and language. Use Preview to check formatting before publishing.

Text and media

Choose plain text, Markdown, or HTML from the text-format menu. HTML is sanitized. Attach images, audio, or video and add descriptions for people who cannot see the media. Upload limits are set by your server; video processing may take time. Use the sensitive-media checkbox and content-warning field when appropriate.

Visibility

VisibilityAccess
PublicPublicly readable and eligible for public feeds
UnlistedPublicly readable, omitted from public feeds
Followers onlyAccepted followers and addressed recipients
Private mentionMentioned accounts
Local onlySigned-in members of this server; not federated

Private mentions are not end-to-end encrypted. Operators of servers storing a post can access it. Edits and deletion requests may reach remote servers late, and cannot remove independent copies.

Polls, scheduling, and edits

The poll control sets choices, duration, and whether multiple choices are allowed. The schedule control accepts a publication time in your configured time zone. Notes and articles can be scheduled; group posts and events cannot. Manage queued posts in Settings → Scheduled posts. Failed publication stays queued for retry. If a post remains overdue, contact your server’s operator or cancel it from that page.

Use a post’s menu to edit it. You can change its text, content warning, language, and attachment descriptions, or remove attachments. The edit form keeps the original visibility and does not add new attachments or polls.

The disclosure beside the post type selector explains how other servers may display that type. Replies use Notes.

Articles

Choose Article below New post, then enter a title and body. The server has a separate character limit for articles. Use Schedule for later to queue an article. Open the disclosure beside the type selector for details of how other servers display it.

Events

Choose Event below New post, then enter an event name, description, and start time. The time zone defaults to your account preference; both dates use the selected zone. Zone labels include the UTC offset for the start date. Optional fields include an end time, venue, online attendance, capacity, and tentative status. Who can attend selects open attendance, approval, invitation, or an external attendance link.

Events federate as ActivityPub Event objects. Remote software may display only a link and may not offer RSVP controls.

Groups

Open a group and use its post action to start a thread. The group composer has a title and an optional link; a link requires a title. Group posts are public and subject to the group’s posting, membership, and moderation rules. A titled ordinary group post is sent as an ActivityPub Page.

Personal custom emoji

Settings → Custom emoji manages your collection. Upload PNG, GIF, or WebP images, or choose Borrow custom emojis from a post or profile’s menu. Your emoji appear alongside server-wide emoji in composers and reaction pickers. They work in post bodies, content warnings, polls, profiles, and reactions.

Your role and the server’s limits control uploads and borrowing. You can rename and recategorize original uploads. To replace a borrowed emoji, delete it and borrow again. Removing upload permission still allows management of existing emoji.

Account settings and safety

Open Settings to change your profile, preferences, languages, privacy, notifications, filters, relationships, security, and data-transfer settings. Profile changes may take time to reach remote servers.

Privacy

Privacy & reach controls follower approval, default post visibility, profile discovery, search-engine indexing, and direct remote media loading. Requiring approval applies to new followers; review existing followers separately. Allowing direct remote media exposes your browser’s IP address to the media host when the browser loads from it.

Filters, mutes, blocks, and reports

Filters hide or warn on matching content in selected contexts. Muting removes an account’s activity from your view. Blocking restricts interaction with that account. Use Report in a post or profile menu to send a case to your server’s moderators. The form may offer to forward it to the remote server as well.

Sign-in security

Security offers TOTP, WebAuthn security keys, active sessions, and sign-in history. Store recovery codes somewhere you can access if you lose your usual factor. Review authorized applications and revoke access you no longer use. Contact your server’s operators if you cannot recover access.

Import, export, and migration

Import & export provides CSV transfers and full account archives. An account archive is a downloadable snapshot; it cannot restore a whole server.

To move followers, first add the old account as an alias at the destination, then start migration from the old account. Other servers process the move asynchronously. Posts and all other account data do not move with followers.

Deletion

Export anything you need before deleting an account. Plamenu queues local cleanup and remote deletion notices. Remote servers and independent archives may retain copies.

Identity proofs (experimental)

Settings → Identity proofs lets you publish an FEP-c390 statement linking your account to an Ed25519 key you own. Verified keys appear on profiles, with a link to the signed statements. A proof establishes control of a key; it does not establish a person’s legal identity or authorize account migration.

Copy the actor ID shown in settings. From the source checkout, create a key and sign a statement locally with uv:

uv run scripts/sign-identity-proof.py --generate --key identity.pem \
  --actor 'https://your-server.example/ap/accounts/your-account-id' > statement.json

Use your actual actor ID. Paste statement.json into settings and publish it. Keep identity.pem private and backed up. To sign for another account with the same key, run the command with its actor ID and omit --generate. Remove proof withdraws the statement and notifies connected peers.

Install a new server

The repository has deployment examples for a Linux binary and an OCI image. Release publication and signing are still being set up. The commands below require a release with the named artifacts and verification instructions. See release preparation for status. For local evaluation from source, use the development guide.

PathHostDeployment setup
Debian binaryA new or existing Linux serverLinux binary, systemd unit, host configuration, and Caddy
OCI imageExisting container infrastructureImage running as UID 10001, with configuration and storage requirements

Container examples cover Docker Compose, Incus, and Proxmox VE.

Requirements

You need a stable public domain, DNS control, ports 80 and 443, PostgreSQL, space for media and independent backups, and an SMTP relay if the server will send registration or password-reset mail. Webxdc also needs DNS for both webxdc.<social-domain> and *.webxdc.<social-domain>.

Choose domain and optional account_domain permanently before federation begins. If handles should use an apex domain while Plamenu runs on a subdomain, read Split-domain handles before installing.

After either installation path, create the first administrator, reboot once, rehearse a restore, and configure external checks for /ready and disk space before inviting members.

Install the binary on Debian

This is the reference deployment: Debian 13, the release binary, PostgreSQL 17 from Debian, Caddy, and systemd. Releases include AMD64 and ARM64 binaries. The static binaries can run on other Linux distributions of the same architecture; adapt the package names and service setup for your host.

The steps assume a fresh, dedicated server and an administrator with sudo. Run the commands section by section, replacing the example values for your server.

Prepare the host

sudo apt-get update
sudo apt-get install -y --no-install-recommends \
  postgresql caddy ffmpeg ca-certificates curl openssl
sudo adduser --system --group --home /var/lib/plamenu \
  --no-create-home plamenu
sudo -u postgres createuser plamenu
sudo -u postgres createdb --owner=plamenu plamenu

The operating-system user and PostgreSQL role have the same name. Debian’s default peer authentication therefore lets Plamenu use the local database socket without storing a database password or opening PostgreSQL to the network.

Install a release

These commands require published release assets (see release status). Replace 0.6.0 with the selected version in the version variable. The commands select the host architecture:

# Replace 0.6.0 with the release you want to install.
version=0.6.0
case "$(uname -m)" in
  x86_64) arch=amd64 ;;
  aarch64) arch=arm64 ;;
  *) echo 'Supported architectures: AMD64 and ARM64' >&2; exit 1 ;;
esac
archive="plamenu-$version-linux-$arch.tar.gz"
mkdir "plamenu-$version"
cd "plamenu-$version"
curl -fLO "https://codefloe.com/plamenu/plamenu/releases/download/v$version/$archive"
tar -xzf "$archive"

sudo install -m 0755 plamenu /usr/local/bin/plamenu
sudo install -m 0644 plamenu.service /etc/systemd/system/plamenu.service
sudo install -d -m 0750 -o root -g plamenu /etc/plamenu
sudo install -m 0640 -o root -g plamenu \
  plamenu.host.toml.example /etc/plamenu/plamenu.toml
sudo install -m 0644 Caddyfile.host.example /etc/caddy/Caddyfile

plamenu-VERSION-linux-ARCH.tar.gz contains one executable, the systemd unit, example Plamenu and Caddy configurations, and the license.

Configure the server

openssl rand -hex 32
sudoedit /etc/plamenu/plamenu.toml
sudoedit /etc/caddy/Caddyfile

In /etc/plamenu/plamenu.toml, replace social.example.com and the encryption secret. Keep the socket database URL and loopback bind for this single-host layout. Configure SMTP before enabling a mail-dependent account flow.

In /etc/caddy/Caddyfile, replace every social.example.com. Point the social domain, webxdc.<social-domain>, and *.webxdc.<social-domain> to the server. The configuration guide explains split-domain handles and why proxy trust must remain narrow.

Validate and start

# CLI commands initialize media storage before systemd creates StateDirectory.
sudo install -d -m 0750 -o plamenu -g plamenu \
  /var/lib/plamenu /var/lib/plamenu/media
sudo -u plamenu /usr/local/bin/plamenu \
  --config /etc/plamenu/plamenu.toml role list
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl daemon-reload
sudo systemctl enable --now postgresql plamenu caddy
# Debian may already have started Caddy with its packaged default config.
sudo systemctl reload caddy
curl -fsS http://127.0.0.1:8420/health
curl -fsS https://social.example.com/ready

/health checks that the process answers. /ready also checks PostgreSQL and worker-supervisor health. If the public check fails, inspect journalctl -u plamenu and use the troubleshooting guide.

Next, create the first administrator.

Run the OCI image

Each release publishes one OCI image tag containing linux/amd64 and linux/arm64. The release page links to the container package and gives the pull command and immutable digest. Docker selects the host’s architecture:

docker pull codefloe.com/plamenu/plamenu:v0.6.0

Replace the version with an available release. Private packages require docker login codefloe.com. Use the published image@sha256:… reference for deployments that must stay on that exact release. Local preparation also retains both tested images; see release preparation.

Runtime requirements

An OCI deployment must provide all of the following:

ConcernRequirement
ProcessThe image entrypoint runs plamenu --config /etc/plamenu/plamenu.toml serve as UID/GID 10001
ConfigurationRead-only file at /etc/plamenu/plamenu.toml, readable by UID 10001
MediaPersistent, writable storage at /var/lib/plamenu/media, owned by UID/GID 10001
ScratchWritable /tmp; allow at least 1 GiB for large media operations
DatabaseA supported PostgreSQL database reachable from the container; do not publish PostgreSQL publicly
HTTPContainer port 8420 behind an HTTPS reverse proxy
Health/health for liveness and /ready for database-aware readiness
IdentityOne serving Plamenu process per database; permanent domain and account_domain
Proxy trusttrusted_proxies contains only the actual proxy addresses or subnet

The root filesystem can be read-only. Drop every Linux capability and set no-new-privileges when the runtime supports it. Plamenu shells out to the FFmpeg and FFprobe already present in the official image.

Pick a runtime

  • Docker Compose is the supplied all-in-one example and includes PostgreSQL and Caddy.
  • Incus runs the same image as a native Incus application container; the guide was exercised against a real Incus deployment.
  • Proxmox VE can import the same image through its native OCI support, currently a Proxmox technology preview.

Other OCI runtimes need the same process, storage, database, and proxy setup. This repository does not supply Kubernetes, Podman, or Nomad deployment recipes.

Docker Compose

The supplied Compose stack runs PostgreSQL, Plamenu, and Caddy. It requires Docker Engine, Compose, and a release image. See release status before using the download examples.

Prepare the deployment

Replace v0.6.0 with the selected release tag and copy its examples:

# Replace v0.6.0 with the release you want to install.
git clone --branch v0.6.0 --depth 1 \
  https://codefloe.com/plamenu/plamenu.git
cd plamenu
cp deploy/.env.example deploy/.env
cp deploy/plamenu.toml.example deploy/plamenu.toml
chmod 600 deploy/.env deploy/plamenu.toml

Before starting:

  1. Put the release’s immutable image@sha256:… reference in deploy/.env.
  2. Replace the PostgreSQL password in .env and plamenu.toml with the same random value.
  3. Set the social and Webxdc domains.
  4. Set encryption_secret to openssl rand -hex 32 and escrow it separately from PostgreSQL and media backups.
  5. If 172.30.0.0/24 overlaps another network, change it in both files.
  6. Configure SMTP before enabling registration or password reset.

Validate and start

docker compose --env-file deploy/.env -f deploy/compose.yml config
docker compose --env-file deploy/.env -f deploy/compose.yml up -d
docker compose --env-file deploy/.env -f deploy/compose.yml ps
curl -fsS https://social.example.com/ready

All three services should become healthy and the public /ready request must succeed. Continue with the first administrator, using the OCI command shown there.

Incus

Use Incus 7.0 LTS or a newer supported release; it can consume OCI images as application containers. This recipe assumes an existing bridge and storage pool, an external PostgreSQL database, a fixed container address, and a reverse proxy managed outside the Plamenu container.

A published Plamenu image is required; see release status.

The commands use default as the storage pool, 10.0.0.42 as the container address, and /root/plamenu.toml as the configuration file. Replace these values for your installation.

Prepare configuration and storage

Create /root/plamenu.toml on the Incus host from deploy/plamenu.toml.example. Use the database’s private address, bind 0.0.0.0:8420, and trust only the reverse proxy.

Create separate configuration and media volumes with the image’s UID/GID:

incus storage volume create default plamenu-config \
  initial.uid=10001 initial.gid=10001 initial.mode=0700
incus storage volume file push /root/plamenu.toml \
  default plamenu-config/plamenu.toml \
  --uid 10001 --gid 10001 --mode 0600
incus storage volume create default plamenu-media size=50GiB \
  initial.uid=10001 initial.gid=10001 initial.mode=0750

Create the application container

Replace REPLACE_WITH_RELEASE_DIGEST with the digest from the release assets, keeping the sha256: prefix:

incus remote add plamenu-registry https://codefloe.com --protocol=oci
incus init \
  "plamenu-registry:plamenu/plamenu@sha256:REPLACE_WITH_RELEASE_DIGEST" \
  plamenu \
  -c boot.autostart=true -c limits.cpu=2 -c limits.memory=2GiB \
  --device "eth0,ipv4.address=10.0.0.42"
incus config set plamenu oci.entrypoint \
  "/bin/sh -c 'until ip route | grep -q ^default; do sleep 1; done; exec plamenu --config /etc/plamenu/plamenu.toml serve'"
incus storage volume attach default plamenu-config \
  plamenu config /etc/plamenu
incus storage volume attach default plamenu-media \
  plamenu media /var/lib/plamenu/media
incus config device add plamenu tmp disk source=tmpfs: path=/tmp size=1GiB \
  initial.uid=10001 initial.gid=10001 initial.mode=1777
incus start plamenu
curl -fsS "http://10.0.0.42:8420/ready"

The route wait is intentional: an Incus application process can start before its default network route appears. Keep port 8420 private and point the host or edge reverse proxy at it. The host proxy must also serve the wildcard Webxdc origin described in deploy/Caddyfile.

Back up the PostgreSQL database and both custom volumes as one recovery set. To upgrade, stop the instance and recreate its root from the new digest while reattaching the same configuration and media volumes. Do not delete the custom volumes with the old instance.

See the upstream Incus OCI remote guide and custom-volume documentation for registry authentication, clusters, and storage-specific behavior.

Proxmox VE

Proxmox VE 9.1 introduced OCI image imports and application containers as a technology preview. See the 9.1 release announcement and check the support status of your installed version. This recipe also needs a Plamenu release image; see release status.

Import and create

In the Proxmox web interface:

  1. Open a storage that accepts container templates and choose Pull from OCI registry.
  2. Pull the immutable codefloe.com/plamenu/plamenu@sha256:… reference from the release assets.
  3. Create an unprivileged container from that imported template. Keep its image entrypoint, allocate a fixed private address, and enable start at boot.
  4. Add persistent storage at /var/lib/plamenu/media and make it writable by UID/GID 10001. Put the secret-bearing configuration at /etc/plamenu/plamenu.toml, readable by that UID.
  5. Give /tmp at least 1 GiB of writable space, connect the container to a private PostgreSQL service, and reverse proxy private port 8420 over HTTPS.

If the first boot stops with Network unreachable, delay the image entrypoint until the container has a default route, as in the Incus guide. Proxmox exposes this as the container entrypoint setting; keep the final command exactly plamenu --config /etc/plamenu/plamenu.toml serve.

Apply every item in the OCI runtime requirements, including the wildcard Webxdc origin and narrow trusted_proxies value.

The import squashes OCI layers into the container root filesystem. An upgrade is therefore a stopped replacement, not an in-place layer swap: back up PostgreSQL and media, import the new digest, create a replacement container, reattach the preserved media/configuration, then verify /ready before retiring the old container. Keep the old recovery set until the new instance has passed a restore rehearsal.

The authoritative workflow is in the current Proxmox VE administration guide, under Container Images → Open Container Initiative (OCI) Images. Storage, network, backup, and cluster commands vary by Proxmox layout, so Plamenu does not prescribe volume IDs or bridge names.

Create the first administrator

The web registration flow cannot bootstrap its own administrator. Create the first local account with the CLI, then assign the built-in Owner role.

Replace alice, alice@example.com, and replace-with-a-unique-password with the owner’s username, email address, and password:

sudo -u plamenu /usr/local/bin/plamenu \
  --config /etc/plamenu/plamenu.toml account add alice \
  --email alice@example.com --password 'replace-with-a-unique-password'
sudo -u plamenu /usr/local/bin/plamenu \
  --config /etc/plamenu/plamenu.toml account set-role alice Owner

Use the same username in both commands. Role names are case-insensitive. To inspect the roles available in this release, run:

sudo -u plamenu /usr/local/bin/plamenu \
  --config /etc/plamenu/plamenu.toml role list

For the Compose deployment, run these commands from the deployment checkout:

docker compose --env-file deploy/.env -f deploy/compose.yml exec plamenu \
  plamenu --config /etc/plamenu/plamenu.toml account add alice \
  --email alice@example.com --password 'replace-with-a-unique-password'
docker compose --env-file deploy/.env -f deploy/compose.yml exec plamenu \
  plamenu --config /etc/plamenu/plamenu.toml account set-role alice Owner

On another OCI runtime, execute the same two plamenu --config … commands as UID 10001 in the running application container.

Open the configured HTTPS domain and sign in. The Admin link should now be available in the main navigation. Use it to set the server name and description, registration policy, server rules, moderation defaults, retention, and mail behavior before inviting members.

Do day-to-day browsing from a separate ordinary account when practical. Keep the owner account protected with a unique password, two-factor authentication, and a recovery path tested by another authorized operator.

All other CLI commands and current flags are in the generated command-line reference.

Operate a server

For initial setup, follow installation and create the first administrator.

TaskGuide
Set domains, database, storage, mail, and proxy settingsConfiguration
Manage registration, accounts, reports, and server policyAdministration
Change the running versionUpgrading
Preserve and recover dataBackup and restore
Watch readiness, resources, and deliveryMonitoring
Diagnose failuresTroubleshooting

Run one serving process per database. Keep the public and handle domains unchanged after federation begins. Back up PostgreSQL and media together while writes are stopped, and preserve the matching encryption secret separately.

Specialized guides cover background queues, federation keys, remote profile history, and Tor/I2P routing.

Configuration

Plamenu reads deployment settings from a TOML file supplied with --config. The reference binary and OCI deployments both use /etc/plamenu/plamenu.toml. Generate the fully commented template for the exact release with:

plamenu --config plamenu.toml config generate

Use the host or OCI example as a starting point. The configuration tests parse both files.

Settings that need an early decision

domain
Permanent HTTPS host used in ActivityPub actor and object identifiers.
account_domain
Optional permanent handle domain. It must equal domain or be a parent of it. Omitting it uses domain.
database_url
PostgreSQL connection string. The Debian host example uses local peer authentication and stores no password. The Compose example’s password must match POSTGRES_PASSWORD in deploy/.env.
encryption_secret and encryption_secret_version
Root used to encrypt federation private keys. Generate a strong independent value, restrict file access, and escrow it outside database and media backups. Follow the key runbook to rotate it; do not simply replace the value.
trusted_proxies
Exact proxy addresses or tight subnets whose forwarding headers are trusted. A broad private network lets unrelated machines forge the client address used by security logs, blocking, and rate limits.
media_dir
Persistent local media root. It must be writable by Plamenu and included in coordinated backups.

Split-domain handles

To host at social.example.com while publishing alice@example.com, set both values before creating accounts:

domain = "social.example.com"
account_domain = "example.com"

The web server at example.com must preserve the complete request URI while redirecting WebFinger, host-meta, and NodeInfo discovery to the hosting domain:

example.com {
	redir /.well-known/webfinger* https://social.example.com{uri} permanent
	redir /.well-known/host-meta* https://social.example.com{uri} permanent
	redir /.well-known/nodeinfo* https://social.example.com{uri} permanent
}

Do not proxy the Plamenu API or sign-in pages through the account domain.

Mail

SMTP is optional for a closed CLI-created installation, but registration, confirmation, password reset, and other mail-dependent workflows need it. Configure the relay, restart Plamenu, and test delivery before allowing those workflows. Keep relay credentials out of version control and limit the sender’s permissions at the provider.

Federation fetch and proxies

Authorized fetch and remote transport affect interoperability and privacy. Route .onion or .i2p destinations with their dedicated proxy settings so ordinary clearnet fetches retain Plamenu’s DNS/IP destination checks. A global proxy is an advanced deployment that requires its own egress controls. Read the Tor and I2P runbook before enabling it.

What belongs in the admin interface

Use the admin interface for registration, retention, media limits, federation policy, trends, and moderation settings. The TOML file configures identity, networking, database, storage, encryption, mail transport, and outbound routing.

Administration and moderation

The administration interface requires a role with the relevant permission. Assign each moderator their own account and role.

Before opening registration

  • Write clear server rules and a concise description of the community.
  • Choose closed, invite, approval, or open registration.
  • Test SMTP confirmation and password reset from an external mailbox.
  • Set upload, retention, and automated-deletion limits that fit available storage and moderation capacity.
  • Decide federation policy and document when the server limits or blocks a remote domain.
  • Create moderation roles with only the permissions each person needs.
  • Confirm members can find a private contact path for urgent reports.

Daily work

Command-line administration

One CLI command can run alongside one plamenu serve process against the same database. Use the same binary version and configuration as the running server; serialize scripts that invoke several CLI commands. A second concurrent CLI command fails immediately, including diagnostic commands that initialize the database before reading it. Commands with specific stopped-write requirements, such as a coordinated backup, still require that maintenance window.

During an upgrade, finish active CLI commands before replacing the server and use the new binary for subsequent administration. See writer-lock troubleshooting if a command is refused or the process exits after losing its database session.

Console administration

The console groups work into accounts, reports, appeals, groups, instance and domain policy, federation diagnostics, trends, invites, rules, announcements, custom emoji, webhooks, roles, terms, settings, and an audit log.

Record a useful reason for moderation actions. Review reports and appeals as distinct stages, and verify whether an object is local, cached from a remote server, or already unavailable before acting. Remote reports and actions cross organizational boundaries and may reveal information to another operator.

Personal custom emoji moderation

The Settings → Custom emoji group sets the per-user limit (0 means unlimited) and the largest accepted emoji file. Open an individual local account’s collection from that account’s moderation page; the custom emoji console provides seven-day trending views for federated origins, all locally hosted emoji, or personal-only emoji. Manage custom emoji holders can disable, rename, recategorize, remove, or promote a personal emoji. Promotion makes one copy instance-wide and retires equivalent personal copies from pickers and quotas while preserving historical references. A conflicting instance shortcode must be changed explicitly; an existing emoji is never overwritten.

The Upload and borrow personal custom emoji role permission is granted to the built-in User role by default and can be revoked independently. The shield menu action is named Import emojis server-wide; member borrowing remains in the ordinary menu as Borrow custom emojis. Trend accounting covers the trailing seven days and ranks distinct users before total public, unlisted, and local post/reaction uses; private and direct activity is excluded.

Webxdc sessions and storage

Settings → Webxdc apps controls package uploads and federated package fetches. Defaults are 256 MiB per package, 512 MiB after expansion, and 256 MiB per individual file. Archive path, entry-count and compression-ratio checks still apply.

The Create Webxdc sessions member permission is enabled by default. Revoke it in Roles to prevent new hosted sessions; joining existing sessions remains available. It grants no staff access. Manage all Webxdc sessions separately grants the Administration → Webxdc console (enabled for Admin and Owner).

Defaults allow 1024 MiB per session, 1024 MiB across sessions created by one local account, and 10240 MiB across the server, including remote caches. An identical package shares one ZIP and one expanded copy across all sessions. Each account’s quota counts its distinct packages once, and the server quota counts each stored package once. Durable updates count separately per session; ephemeral packets are not stored. These are payload budgets, excluding database row/index overhead and browser-local storage. The session budget must cover the package and expanded limits combined; the account budget must cover one session.

The console lists hosted sessions and remote caches, including ended sessions, with package size, reference count and session-data size. Session details show how much deletion would release. End or delete a hosted session through the normal federated lifecycle, or remove a remote cache and leave for all local participants. Actions are audited. Shared files are released only after the last referencing session is removed. Existing duplicates are consolidated on upgrade. Members can see their quota usage on the Apps page.

Changes take effect without a restart. Existing apps remain available when limits are lowered; new uploads, fetches and durable updates must fit the new limits. The upload form shows the current package limit and reports rejected uploads without discarding the session name and description.

Federation policy

Account-level mute/block, domain moderation, and server-wide federation policy solve different problems. Prefer the narrowest effective control. Before a domain-wide action, assess effects on existing follows, cached content, pending deliveries, and members who rely on contacts there.

Use federation debug and the read-only plamenu federation commands to gather evidence. A remote timeout does not by itself prove a block; DNS, TLS, signature verification, authorized fetch, overloaded queues, and remote policy can produce similar symptoms.

High-risk actions

Account deletion, group deletion, key revocation, key-store contraction, and server self-destruct are intentionally consequential. Read their command help and specialized runbooks, take a current backup where recovery is possible, and verify the target before running the command. Self-destruct is irreversible from the federation’s perspective even though local data remains while notices drain.

Upgrade

Experimental releases can change the database or configuration incompatibly. Check the release notes for an upgrade path from your version. If none is documented, test on a restored copy before changing the live server.

Before changing the binary or image

  1. Record the exact Plamenu version and binary checksum or running image@sha256:…, configuration, PostgreSQL version, and free space.
  2. Read the release notes for migrations, configuration changes, removals, and post-upgrade work.
  3. Run plamenu federation keys audit with the current binary.
  4. Make a coordinated stopped-write database and media backup. Verify that the matching encryption secret is independently available.
  5. Test the restore and upgrade on a separate host or isolated project when the release changes storage, identity, or federation behavior.

Upgrade a binary installation

Download plamenu-VERSION-linux-amd64.tar.gz or plamenu-VERSION-linux-arm64.tar.gz for your host and the selected release, then extract it into a new directory. The archive contains plamenu at its root. From that directory, after the backup completes:

sudo systemctl stop plamenu
sudo install -m 0755 ./plamenu /usr/local/bin/plamenu.new
sudo mv /usr/local/bin/plamenu /usr/local/bin/plamenu.previous
sudo mv /usr/local/bin/plamenu.new /usr/local/bin/plamenu
sudo systemctl start plamenu
sudo journalctl -u plamenu -f

Keep plamenu.previous only as provenance and a recovery aid. Do not run it against a database already migrated by the new version unless the release notes explicitly allow that.

Upgrade Docker Compose

Update PLAMENU_IMAGE to the release’s immutable digest, pull it, and recreate only the Plamenu service:

docker compose --env-file deploy/.env -f deploy/compose.yml pull plamenu
docker compose --env-file deploy/.env -f deploy/compose.yml up -d plamenu
docker compose --env-file deploy/.env -f deploy/compose.yml logs -f plamenu

For Incus, Proxmox VE, or another OCI runtime, create a replacement root from the new digest and reattach the preserved configuration and media storage. Do not delete the preserved volumes. Forward migrations run before the server accepts traffic.

In another terminal, wait for the public /ready endpoint. Then test owner and ordinary sign-in, a post with media, local search, e-mail, a remote follow or mention, queue health, and the administration pages affected by the release.

Rollback

Do not point an older binary at a database after a forward migration unless the release instructions explicitly say that is safe. A rollback normally means restoring the pre-upgrade PostgreSQL and media pair, restoring the matching configuration and encryption secret, and starting the previous binary or immutable image. That discards writes accepted after the backup, so make the decision before reopening the server widely.

Retain the pre-upgrade recovery set until the new version has run through a normal workload and at least one fresh backup has passed a restore test.

Back up and restore

A usable Plamenu recovery set contains:

  • a PostgreSQL dump;
  • the media volume from the same stopped-write window;
  • the matching Plamenu binary or immutable OCI image, its PostgreSQL major version, and the service or container configuration;
  • the federation-key encryption secret version needed by that database, obtained from its separate escrow location.

Keep the secret-bearing .env and TOML configuration in a separate protected configuration backup. Do not put the only copy of the encryption root inside the database or media archive. Without it, restored private federation keys cannot be used.

Create a consistent backup

The Debian binary example stops Plamenu while PostgreSQL and media are captured. Replace 2026-09-09 with the backup date; use a new directory each time:

# Use a new directory for each backup.
mkdir -m 700 plamenu-backup-2026-09-09

sudo systemctl stop plamenu
# The invoking operator owns the backup directory and opens the output file.
# shellcheck disable=SC2024
sudo -u plamenu pg_dump -Fc plamenu > plamenu-backup-2026-09-09/plamenu.dump
sudo tar -C /var/lib/plamenu/media -czf \
  plamenu-backup-2026-09-09/plamenu-media.tgz .
sudo cp /etc/systemd/system/plamenu.service /etc/caddy/Caddyfile plamenu-backup-2026-09-09/
/usr/local/bin/plamenu --version > plamenu-backup-2026-09-09/plamenu-version.txt
sudo chown "$USER" plamenu-backup-2026-09-09/*
sudo systemctl start plamenu

The archive directory is created with restrictive permissions. Copy it to independent storage. For an automated job, add an exit trap that restarts Plamenu when an intermediate backup command fails.

For Compose, run from the deployment checkout. Replace the backup date and, if you renamed the Compose project, the plamenu_media-data volume name:

# Run from the deployment checkout; use a new directory for each backup.
mkdir -m 700 plamenu-backup-2026-09-09

docker compose --env-file deploy/.env -f deploy/compose.yml stop plamenu
docker compose --env-file deploy/.env -f deploy/compose.yml exec -T db \
  pg_dump -Fc -U plamenu plamenu > plamenu-backup-2026-09-09/plamenu.dump
docker run --rm \
  -v plamenu_media-data:/data:ro \
  -v "$PWD/plamenu-backup-2026-09-09":/backup \
  alpine:3.22 tar -C /data -czf /backup/plamenu-media.tgz .
grep '^PLAMENU_IMAGE=' deploy/.env > plamenu-backup-2026-09-09/image-reference.txt
cp deploy/compose.yml deploy/Caddyfile plamenu-backup-2026-09-09/
sudo chown "$USER" plamenu-backup-2026-09-09/*
docker compose --env-file deploy/.env -f deploy/compose.yml start plamenu

For another OCI runtime, stop the application container, dump PostgreSQL, and snapshot or archive the persistent media volume before starting the container again. A runtime snapshot of the container root is not a database backup.

Restore a server

Use a replacement host with an empty database and media storage. Install the same Plamenu version and PostgreSQL major version as the backup; attempt any upgrade after recovery. Keep the old instance stopped when the replacement starts, so only one server uses the federation identity.

Restore the original domains and encryption secret from the protected configuration backup. Changing the domain does not migrate the actor and post URLs stored in the database. Adapt host-specific paths and database addresses if the replacement host differs.

Debian binary installation

Follow the binary installation guide through host preparation and release installation. Restore the saved systemd unit, Caddy configuration, and /etc/plamenu/plamenu.toml, keeping the TOML readable only by root and the plamenu group. Do not run the guide’s startup or account creation commands: the database must still be empty.

From the directory containing your backup, replace the example date and run:

sudo systemctl stop plamenu
# The invoking operator owns and opens the backup; pg_restore runs as plamenu.
# shellcheck disable=SC2024
sudo -u plamenu pg_restore --exit-on-error --no-owner \
  --dbname=plamenu < plamenu-backup-2026-09-09/plamenu.dump
sudo install -d -m 0750 -o plamenu -g plamenu \
  /var/lib/plamenu /var/lib/plamenu/media
sudo tar -C /var/lib/plamenu/media -xzf \
  plamenu-backup-2026-09-09/plamenu-media.tgz
sudo chown -R plamenu:plamenu /var/lib/plamenu/media
sudo -u plamenu /usr/local/bin/plamenu \
  --config /etc/plamenu/plamenu.toml federation keys audit
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl daemon-reload
sudo systemctl enable --now postgresql plamenu caddy
sudo systemctl reload caddy

Docker Compose

On a fresh Docker host, restore deploy/compose.yml, deploy/Caddyfile, deploy/.env, and deploy/plamenu.toml. Set PLAMENU_IMAGE to the backed-up image digest, and retain the database name, user, and password from the saved configuration. The commands below use the default database and user plamenu.

Run from the deployment directory, with the backup directory alongside deploy/. Replace the date and, if you changed the Compose project name, the plamenu_media-data volume name. These commands create fresh volumes, restore the data, audit the federation keys, and then start the services:

docker compose --env-file deploy/.env -f deploy/compose.yml up -d --wait db
docker compose --env-file deploy/.env -f deploy/compose.yml exec -T db \
  pg_restore --exit-on-error --no-owner -U plamenu -d plamenu \
  < plamenu-backup-2026-09-09/plamenu.dump
docker compose --env-file deploy/.env -f deploy/compose.yml create plamenu
docker run --rm -i --network none \
  -v plamenu_media-data:/data \
  alpine:3.22 tar -C /data -xzf - \
  < plamenu-backup-2026-09-09/plamenu-media.tgz
docker compose --env-file deploy/.env -f deploy/compose.yml run --rm --no-deps plamenu \
  federation keys audit
docker compose --env-file deploy/.env -f deploy/compose.yml up -d --wait

For another container runtime, restore the dump into an empty PostgreSQL database and extract the media archive into its persistent volume. The image runs as UID/GID 10001; retain that ownership when restoring media. Attach the saved configuration before starting the container.

Check the restored server

Check /ready through HTTPS, then sign in with an existing account and open old posts and their media. Test a new post, media upload, and delivery to a remote account. Inspect worker logs and the delivery queue if anything stalls. The restore examples run federation keys audit before starting the server; resolve any reported missing or unreadable keys before proceeding.

For a rehearsal with a real server’s backup, isolate the copy from public federation and outbound mail before starting it. Use private DNS overrides for the original domains and an isolated peer for delivery checks. A separate Compose project alone does not prevent background jobs from reaching real recipients.

Repository restore drill

./dev backup-restore creates disposable Docker projects, posts and media, backs up one server, destroys its volumes, and restores it. It checks existing credentials, post identities, media bytes, pending delivery, and new federation in both directions, then removes its own test resources.

The drill requires Bash, Docker Compose, Git, sha256sum, and diff. It builds a native image by default. To use an existing candidate:

PLAMENU_DRILL_SKIP_BUILD=true \
PLAMENU_DRILL_IMAGE=your-candidate-image \
./dev backup-restore

Retain the log, script revision, and tested image identity with release evidence. ./dev smoke-install runs the initial installation and exchange checks only. Both use an isolated network and local CA. Test public DNS/TLS setup and recovery from your own backups separately.

Retention

Keep multiple recovery points and at least one off-host copy. A synchronized corruption, compromised credential, or operator mistake can make the newest backup useless. Periodically restore the exact files your scheduled job produces; “backup completed” is not evidence that the result starts.

Monitor the server

Monitor public HTTPS readiness, resource use, delivery queues, and backups.

Probes

  • GET /health is liveness: the process answers.
  • GET /ready checks PostgreSQL and worker-supervisor health. Use it for external uptime alerts, container health, and traffic admission.

Probe /ready through the public HTTPS origin so DNS, Caddy, TLS, the backend, and PostgreSQL are all exercised. Also retain a local probe to distinguish edge failure from application failure.

Alert on

  • sustained /ready failure or elevated HTTP 5xx responses;
  • repeated service/container restarts or worker crash messages;
  • PostgreSQL connection exhaustion, slow queries, and database storage;
  • media and temporary-transcode free space;
  • outbound delivery queue age, due work, and large per-host backlogs;
  • mail queue failure when account recovery depends on e-mail;
  • backup age and the last successful restore rehearsal;
  • certificate expiry and DNS changes for both the social and Webxdc hosts.

Logs and queue inspection

Plamenu writes structured text to stdout and stderr. Set RUST_LOG in the deployment environment to adjust filtering; avoid debug logging indefinitely on a busy server because URLs and operational context can be sensitive.

On the binary deployment, follow logs with journalctl -u plamenu -f. Compose uses docker compose … logs -f plamenu; Incus uses incus console plamenu --show-log. Use the equivalent facility for another runtime.

Inspect outbound work without changing it:

sudo -u plamenu plamenu --config /etc/plamenu/plamenu.toml \
  federation queue inspect
sudo -u plamenu plamenu --config /etc/plamenu/plamenu.toml \
  federation reachability

The queue guide explains retry, leasing, host fairness, and the few operations with different guarantees.

Capacity signals

No general minimum hardware requirement has been established. Measure CPU, memory, database growth, and media storage under the workload you expect. Video processing and account archives also need temporary space. Keep room for a restore and alert before storage is exhausted.

Increase the default database pool only after observing pool waits and checking PostgreSQL memory and connection headroom. CPU saturation in password hashing or media processing is not fixed by adding database connections.

Troubleshoot

Record the symptom and where it occurs. Save timestamps, the public URL or handle, local account, remote host, HTTP status, and relevant log lines before restarting or deleting anything.

The site does not become ready

  1. Check systemctl status plamenu postgresql or the equivalent container and database status in your OCI runtime.
  2. Inspect Plamenu and PostgreSQL logs.
  3. Request /health and /ready locally, then through public HTTPS.
  4. Verify database_url, database authentication, and media/scratch free space.
  5. Check whether another Plamenu process holds the single-writer database lock.
  6. Confirm the reverse proxy can reach port 8420 and that trusted_proxies exactly matches the actual proxy path.

Do not treat liveness success with readiness failure as healthy; it commonly points to PostgreSQL or a worker supervisor.

Writer-lock errors

Plamenu permits one serving process and one CLI command per database. If startup reports a held writer lock, check for another service, container, or CLI command using that database. Let the active command finish or stop the identified duplicate process. These are session locks: they disappear when the owning database session ends and do not require deleting a lock file or database row.

The original lock connection is monitored throughout initialization and operation. A connection failure or five-second heartbeat timeout makes the process exit; it does not reconnect and continue under an unverified lock. Correlate the exit with PostgreSQL restarts, connection termination, and network failures. Before retrying an interrupted mutating CLI command, inspect whether its action already committed. See the deployment boundary for the limits of this guard.

Sign-in or e-mail fails

Check the exact username/e-mail, server time, SMTP configuration, relay logs, and whether the flow requires confirmed mail. For an existing account, an authorized operator can set new credentials with plamenu account passwd. Never ask a member to send their old password or TOTP secret.

A remote account or post cannot be found

Try the exact handle and then the canonical public URL. Use read-only diagnosis:

plamenu federation webfinger user@example.com
plamenu federation fetch https://example.com/@user
plamenu federation reachability

Check DNS, TLS, WebFinger links, HTTP signatures, authorized fetch, local and remote domain policy, and whether the destination resolves to a forbidden private address. Search is not a global crawl and will not reveal private or unavailable objects.

Federation delivery is delayed

Run plamenu federation queue inspect, then correlate the oldest due jobs with reachability output and logs. A growing global queue suggests local database, network, or worker trouble; one remote host with backoff more often indicates a peer-specific or policy problem. Read background queues before altering state.

Media fails

Check persistent media and /tmp free space, file permissions, FFmpeg logs, configured limits, and whether the failure affects uploads, remote cache, or only transcoding. plamenu media reconcile reports orphan candidates without deleting them. Use --delete only during low upload activity after reviewing the report and taking a backup.

After an upgrade

Compare the running version and binary checksum or image digest with the intended release, then read its migration and configuration notes again. Do not start an older binary against a migrated database unless the release explicitly permits it. Follow the rollback procedure.

Before asking for help

Include the Plamenu version and full build identity, deployment method, architecture, redacted configuration relevant to the symptom, reproduction steps, timestamps, and a short log excerpt. Remove passwords, tokens, cookies, private keys, encryption roots, private post content, and member e-mail addresses. Report security vulnerabilities through the private process in the repository’s SECURITY.md.

Background queues

Plamenu uses PostgreSQL-backed queues. Workers claim due rows with FOR UPDATE SKIP LOCKED.

Delivery guarantees

Most queues are at-least-once. Claiming a job moves its due time forward but does not delete it. Completion deletes the row; a crash lets the lease expire and the job becomes eligible again. Retryable failures reschedule with backoff, and per-queue attempt limits prevent poison jobs from looping forever. Handlers on leased queues must therefore be idempotent or safely repeatable.

Account archives and bulk imports use scheduled/in_progress state plus a claimed_at lease. Startup and periodic sweeps return stale claims to scheduled.

QueueSemanticsRecovery
deliveries, mail, webhooks, Web Pushat-least-oncelease expiry and bounded retry
media processing/account mediaat-least-oncelease expiry; remote work backs off
quote verificationat-least-oncebounded retry, then remains pending
reply fetch, link verification/crawlleased single-attempt worklease expiry bounds crash loops
media cleanupat-least-oncelease expiry and bounded retry
account archives, bulk importsat-least-once state leasestale-claim sweep
scheduled statusesleased retry; atomic publicationfive-minute lease; queue removal commits with the post and delivery jobs
poll-expiry side effectsat-most-oncedurable close stamp, no notification replay

Scheduled posts remain listed while claimed or waiting for retry. A failed attempt becomes eligible again five minutes after its claim; the chosen publication time is preserved. Claim generations prevent an old worker from publishing after a retry, cancellation, or reschedule. Queue removal, the post, media attachment, and outgoing delivery jobs commit together, so retrying cannot create a second post. Streaming and optional post-commit notifications remain best-effort.

If a scheduled post stays overdue, inspect the scheduled status publish failed log entry. Fix the reported cause or cancel the entry. Rescheduling clears the retry delay. Failed entries are retained until publication or cancellation.

Poll-expiry notifications remain at-most-once: a crash after the close stamp can lose those side effects, and replay is not attempted.

Admission and fairness

Web Push claims rotate across recipient accounts and deliver with bounded concurrency, so one user or slow endpoint cannot monopolize the queue. Revoking a token deletes its push subscription and queued jobs.

CSV import upload is limited per account and IP before its body is buffered; unfinished imports and total pending rows also have server-wide caps. Workers skip suspended accounts and re-check suspension between write windows.

Account purge behavior

An account purge keeps its tombstone row, so queue cleanup is explicit:

  • retain delivery jobs for the final Delete(Actor) or blanked actor update;
  • cancel imports, archive builds, account-media work, link verification, and scheduled statuses;
  • delete Web Push work with the revoked subscriptions;
  • enqueue media cleanup for files and stored archive ZIPs.

Media cleanup is inserted transactionally with row deletion. This ensures a crash cannot leave a database-deleted attachment permanently retrievable from storage. plamenu media reconcile finds older orphaned files.

Adding a queue

Use a lease whenever accepted work must survive process failure, and make the operation safe to repeat. If replay is more harmful than rare loss, document the at-most-once decision at the claim site and add it to the table above.

Manage federation signing keys

Plamenu stores local ActivityPub signing keys encrypted in PostgreSQL. The encryption_secret in the server configuration unlocks them. Keep that secret in a protected configuration backup separate from the database.

The commands below use the Debian service account and configuration path. For a container, run the same CLI commands as the application user with its configuration mounted. Use the same binary version as the running server.

Check the keys

sudo -u plamenu plamenu --config /etc/plamenu/plamenu.toml federation keys audit

The audit checks that private keys are encrypted, can be decrypted with the configured keyring, and match their public keys. If it fails, check the secret versions and restore the matching configuration before replacing any keys. Do not include private keys, ciphertext, or encryption secrets in bug reports.

Rotate the encryption secret

This changes encryption at rest while keeping the published signing keys. Generate a new secret:

openssl rand -hex 32

In the server configuration, give it a new positive version and retain the old secret for decryption:

encryption_secret = "replace-with-the-new-secret"
encryption_secret_version = 2
encryption_previous_secrets = ["1:replace-with-the-old-secret"]

Restart Plamenu with that configuration, then re-encrypt existing keys:

sudo -u plamenu plamenu --config /etc/plamenu/plamenu.toml federation keys rewrap
sudo -u plamenu plamenu --config /etc/plamenu/plamenu.toml federation keys audit

Rewrapping works in batches and can resume after interruption. rewrap --limit 100 processes a limited batch. Once rewrapping and the audit pass, the old secret can be removed from the live configuration. Retain it in protected backup storage for as long as any retained database backup needs it. Never assign different secrets the same version number.

Rotate the published signing keys

Rotate an account key or the instance key, selecting RSA or Ed25519:

sudo -u plamenu plamenu --config /etc/plamenu/plamenu.toml \
  federation keys rotate-account alice --algorithm rsa
sudo -u plamenu plamenu --config /etc/plamenu/plamenu.toml \
  federation keys rotate-account alice --algorithm ed25519
sudo -u plamenu plamenu --config /etc/plamenu/plamenu.toml \
  federation keys rotate-instance --algorithm rsa
sudo -u plamenu plamenu --config /etc/plamenu/plamenu.toml \
  federation keys rotate-instance --algorithm ed25519

A new key is published immediately and starts signing after five minutes by default. An actor update signed with the old key announces the replacement. The old key remains available for a seven-day overlap. Use --overlap-hours and --activation-delay-seconds to adjust those windows; the activation delay must be positive so peers have time to learn the new key.

To end use of an old key explicitly, supply its exact URI:

sudo -u plamenu plamenu --config /etc/plamenu/plamenu.toml \
  federation keys retire 'https://social.example.com/actor#old-key'

Use revoke in place of retire for a compromised key. Revoked, retired, expired, and not-yet-active keys cannot sign or verify new traffic. Their public records remain available for audit.

Restore or recover a lost secret

Restore the database and the encryption-secret versions that can decrypt its keys. An older database backup may need a version no longer used by the live server. Test both backups together.

Without any matching secret, the encrypted keys cannot be recovered. Recover the secret from its backup first. Replacing signing keys is a separate recovery operation that can disrupt federation with peers caching the old keys.

Manage remote profile history

Remote history fetches older public and unlisted posts when a signed-in member opens a remote Person or Service profile. It is disabled by default.

Enable or disable

With Manage federation permission, open Administration → History and turn on Enable remote history hydration. Leave Resolve bare item IRIs off initially; it adds individual object fetches for servers whose outbox pages contain links instead of embedded posts.

Open a known remote profile while signed in. Its history state should progress from queued or fetching to partial or complete. Check the History page for failures, queue growth, and storage by origin. Media is fetched when viewed; loading history should not create notifications or outgoing activities.

To stop new work, clear Enable remote history hydration. An active request may finish within its 30-second limit. Queued jobs remain and resume when the feature is enabled again; cached posts remain readable.

Limits

WorkLimit
Items examined per page20
Pages per member action1
Collection documents fetched initially2
Concurrent jobs per origin1
Queued jobs per origin2
JSON response2 MiB
Job duration30 seconds
Individual object fetches with bare-IRI resolution5 per page

These limits are fixed. Operators can change retention from 1 to 3650 days; its default is 90 days.

Read the history state

StateMeaning or next step
idleNo active fetch
queued / fetchingWork is pending or running
partialMore may be available; the member can choose Load older posts
completeThe observed collection had no unfetched next page
unsupportedThe collection, attribution, authorization, or federation policy prevented this attempt
backoffA transport error, timeout, or rate limit delayed retry; check retry_at

The profile panel and API expose attempts, retry time, and item counts. Use the admin failure and origin tables to narrow down a problem, then inspect worker logs. Repeated requests coalesce. Restarting the server does not clear stored backoff; expired job leases are reclaimed.

If images fail, check the media proxy and origin availability. History images are cached on demand and videos when played. A member’s Allow direct remote media preference permits a direct-origin fallback after proxy failure.

Retention and pruning

An hourly sweep removes up to 200 stale history posts. Viewing an actor’s profile refreshes its retention clock. Posts received through live delivery or referenced by local bookmarks, favourites, pins, reactions, votes, replies, boosts, quotes, or reports are preserved.

Review Cold statuses, Stored text bytes, and Cold storage by origin before shortening retention. Prune one batch now applies the retention rule to at most 200 rows and queues media cleanup. Avoid deleting status rows manually, which bypasses those checks. Pruned posts can be fetched again only while the remote server still provides them.

Client API

The endpoints use the OAuth read scope:

  • GET /api/v1/accounts/:id/remote_history reads local history state.
  • POST /api/v1/accounts/:id/remote_history/fetch requests more history.
  • An authenticated first-page GET /api/v1/accounts/:id/statuses can also queue the initial fetch. Anonymous, paginated, and pinned-only requests do not.

Unexpected activity

Disable history fetching if cached history appears in home/public/list/tag feeds, streaming, or full-text search, or if importing it creates notifications or outgoing federation work. Retain the affected actor and post IDs, timestamps, and logs for investigation. Cached history should remain confined to profile history until it is received through normal delivery.

Hidden-service transport

Plamenu can route outbound federation and media requests to .onion and .i2p destinations through an external SOCKS5 proxy. Run Tor or an I2P proxy separately; the server keeps its existing ActivityPub identity and federation policy. This configuration covers outbound connections.

Configuration

Prefer per-network routes so clearnet requests continue through Plamenu’s local DNS/IP destination checks:

[federation]
onion_proxy_url = "socks5h://127.0.0.1:9050"
i2p_proxy_url = "socks5h://127.0.0.1:4447"
no_proxy = ["example.com"]

Use socks5h://, not socks5://: the h delegates hidden-name resolution to the proxy. Plamenu rejects the local-resolution form.

A global proxy_url routes clearnet names through the proxy too. When allow_private_fetch=false, configuration is rejected unless trust_proxy_destination_filtering=true. Set that acknowledgement only when the proxy independently blocks loopback, private/link-local ranges, carrier grade NAT, cloud metadata endpoints, and DNS rebinding.

Hidden-service URLs may use plain HTTP because overlay transport supplies the secure channel. Plain HTTP remains rejected for ordinary hosts. no_proxy takes precedence over all proxy routes.

Runtime behavior

  • WebFinger, actor/object fetches, delivery, and remote media use the same destination-aware client selection.
  • Onion deliveries and fetches are serialized because concurrent Tor requests are failure-prone. Clearnet work retains normal concurrency.
  • Queue retry and backoff behave exactly as for clearnet failures. A missing or unavailable proxy fails cleanly; it does not fall back to direct DNS.
  • Reachability state is separated by transport, so an onion failure does not mark the same clearnet host unreachable.

The live local acceptance test is e2e/tests/test_tor_federation.py. It covers discovery, follow, signed delivery, remote media, and the absence of direct .onion DNS traffic. Run it through the E2E environment documented in DEVELOPMENT.md.

Deployment examples

The Debian host example binds only to loopback and uses PostgreSQL peer authentication over a local socket:

# Debian host deployment. Copy to /etc/plamenu/plamenu.toml, replace every
# example value, and keep the file readable only by root and the plamenu group.
# The hosting domain is permanent after federation begins.
domain = "social.example.com"
# Optional: use alice@example.com handles while serving Plamenu itself from
# social.example.com. This is also permanent and needs well-known redirects.
# account_domain = "example.com"
bind = "127.0.0.1:8420"

# On a single host, the plamenu operating-system user authenticates to the
# matching PostgreSQL role over the local socket. No database password is
# stored in this file and PostgreSQL is not exposed to the network.
database_url = "postgres://plamenu@%2Fvar%2Frun%2Fpostgresql/plamenu"
media_dir = "/var/lib/plamenu/media"
trusted_proxies = ["127.0.0.1/32", "::1/128"]

authorized_fetch = true
authorized_fetch_unsigned = "profile"
csp_reporting = false
encryption_secret = "replace-with-output-of-openssl-rand-hex-32"
encryption_secret_version = 1
# encryption_previous_secrets = []

# Add SMTP before enabling registration or password reset.
# [smtp]
# server = "smtp.example.com"
# port = 587
# login = "username"
# password = "replace-me"
# from_address = "Plamenu <notifications@example.com>"
# starttls = "always"

The OCI/Compose example binds to the container network and reaches PostgreSQL by service name:

# Copy to plamenu.toml, chmod 600, and keep it out of version control.
# The hosting domain is permanent after federation begins.
domain = "social.example.com"
# Optional: use short handles such as alice@example.com while keeping every
# Plamenu URL on social.example.com. This is permanent too; before enabling,
# configure example.com's three well-known redirects as documented in
# docs/operators/configuration.md.
# account_domain = "example.com"
# Also configure DNS/TLS for webxdc.social.example.com. Webxdc packages are
# isolated there and never receive the host's login cookie.
bind = "0.0.0.0:8420"
database_url = "postgres://plamenu:replace-with-the-same-password@db/plamenu"
media_dir = "/var/lib/plamenu/media"
# The Caddy container reaches Plamenu over this Compose subnet. If you override
# PLAMENU_BACKEND_SUBNET in .env, update this value to exactly the same subnet.
# Trusting only that subnet (not all private ranges) stops unrelated hosts from
# forging client IPs.
trusted_proxies = ["172.30.0.0/24"]
authorized_fetch = true
authorized_fetch_unsigned = "profile"
# CSP violation reports can include browsed and blocked-resource URLs. Keep the
# telemetry endpoint absent on live instances unless debugging it deliberately.
csp_reporting = false
encryption_secret = "replace-with-output-of-openssl-rand-hex-32"
encryption_secret_version = 1
# Keep old roots only while rewrapping/restoring older backups, as
# "version:secret" entries. See docs/FEDERATION_KEY_OPERATIONS.md.
# encryption_previous_secrets = []

# Add SMTP before enabling public registrations or password reset.
# [smtp]
# server = "smtp.example.com"
# port = 587
# login = "username"
# password = "replace-me"
# from_address = "Plamenu <notifications@example.com>"
# starttls = "always"

# Outbound proxy routing — only needed to federate with .onion/.i2p
# instances via a local Tor/I2P daemon. Proxy URLs must be socks5h://
# (proxy-side name resolution) or http(s)://. See `plamenu config generate`
# for the full commentary.
# [federation]
# Prefer these per-overlay routes: clearnet then keeps Plamenu's DNS/IP SSRF
# checks. A global proxy_url requires a separately enforced egress ACL and
# trust_proxy_destination_filtering = true; see docs/operators/configuration.md.
# onion_proxy_url = "socks5h://127.0.0.1:9050"
# i2p_proxy_url = "socks5h://127.0.0.1:4447"
# no_proxy = []

Copy the example matching the deployment, replace every placeholder, and protect the result from other users. Plamenu’s tests parse both exact files with the real configuration loader.

For every available setting and current default, run plamenu config generate with the release binary. See configuration for the decisions that must be made before federation begins.

Command-line reference

This page is generated from the running program’s Clap help tree. Do not edit it by hand; run ./dev docs-generate after changing a command or flag. All commands except config generate read the global --config TOML file.

plamenu

$ plamenu --help
A fast, small ActivityPub server

Usage: plamenu [OPTIONS] <COMMAND>

Commands:
  config         Create Plamenu configuration
  serve          Run the HTTP server (includes the delivery worker)
  account        Manage local accounts
  group          Manage local groups
  post           Post a status as a local account (enqueued for follower delivery)
  follow         Follow a remote account (`user@domain`) as a local account
  emoji          Manage this instance's custom emoji
  role           Inspect moderation roles
  rule           Manage this instance's published rules (server policies)
  announcement   Manage server announcements (shown to logged-in users)
  media          Maintenance tasks for stored media
  federation     Read-only federation diagnostics (fetch objects, resolve handles, inspect the delivery queue and the reachability breaker)
  self-destruct  Erase the server from the federation (Mastodon's `tootctl self-destruct`): broadcast account deletion notices to every known server, and serve 410 Gone while they go out. Irreversible; always asks for confirmation. Re-run to see the wind-down's progress
  help           Print this message or the help of the given subcommand(s)

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help
  -V, --version          Print version

plamenu config

$ plamenu config --help
Create Plamenu configuration

Usage: plamenu config [OPTIONS] <COMMAND>

Commands:
  generate  Write a documented starter TOML file to the --config path
  help      Print this message or the help of the given subcommand(s)

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu config generate

$ plamenu config generate --help
Write a documented starter TOML file to the --config path

Usage: plamenu config generate [OPTIONS]

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
      --force            Replace an existing file instead of preserving it
  -h, --help             Print help

plamenu serve

$ plamenu serve --help
Run the HTTP server (includes the delivery worker)

Usage: plamenu serve [OPTIONS]

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu account

$ plamenu account --help
Manage local accounts

Usage: plamenu account [OPTIONS] <COMMAND>

Commands:
  add       Create a local account, optionally with login credentials
  passwd    Set (or replace) the login credentials of an existing account
  set-role  Grant a moderation role to an account (by role name, e.g. `Owner`), or clear it with `--clear`. Use this to bootstrap the first administrator
  rename    Change the human/discovery handle of an immutable-ID account. The old profile URL remains reserved as a redirect; the AP actor ID is stable
  alias     Manage an account's aliases (`alsoKnownAs`) — declare an alias so another account is allowed to migrate its followers here
  migrate   Migrate this account to another (remote) account, re-pointing local followers and telling remote followers via `Move`. The destination must already list this account in its aliases
  help      Print this message or the help of the given subcommand(s)

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu account add

$ plamenu account add --help
Create a local account, optionally with login credentials

Usage: plamenu account add [OPTIONS] <USERNAME>

Arguments:
  <USERNAME>

Options:
      --config <CONFIG>              TOML configuration file used by every command except `config generate` [default: plamenu.toml]
      --display-name <DISPLAY_NAME>  [default: ""]
      --email <EMAIL>                Optional e-mail: an alternate login identifier that also enables password reset (requires --password)
      --password <PASSWORD>          Password for signing in (via username, or --email when given)
  -h, --help                         Print help

plamenu account passwd

$ plamenu account passwd --help
Set (or replace) the login credentials of an existing account

Usage: plamenu account passwd [OPTIONS] --password <PASSWORD> <USERNAME>

Arguments:
  <USERNAME>

Options:
      --config <CONFIG>      TOML configuration file used by every command except `config generate` [default: plamenu.toml]
      --email <EMAIL>        Optional e-mail; omitting it keeps any address already stored
      --password <PASSWORD>
  -h, --help                 Print help

plamenu account set-role

$ plamenu account set-role --help
Grant a moderation role to an account (by role name, e.g. `Owner`), or clear it with `--clear`. Use this to bootstrap the first administrator

Usage: plamenu account set-role [OPTIONS] <USERNAME> [ROLE]

Arguments:
  <USERNAME>
  [ROLE]      Role name (case-insensitive); omit with `--clear`

Options:
      --clear            Remove any assigned role instead of setting one
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu account rename

$ plamenu account rename --help
Change the human/discovery handle of an immutable-ID account. The old profile URL remains reserved as a redirect; the AP actor ID is stable

Usage: plamenu account rename [OPTIONS] <USERNAME> <NEW_USERNAME>

Arguments:
  <USERNAME>
  <NEW_USERNAME>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu account alias

$ plamenu account alias --help
Manage an account's aliases (`alsoKnownAs`) — declare an alias so another account is allowed to migrate its followers here

Usage: plamenu account alias [OPTIONS] <COMMAND>

Commands:
  add     Declare an alias (a `user@domain` acct or actor URI) on an account
  list    List an account's declared aliases
  remove  Remove a declared alias (by its stored URI)
  help    Print this message or the help of the given subcommand(s)

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help
plamenu account alias add
$ plamenu account alias add --help
Declare an alias (a `user@domain` acct or actor URI) on an account

Usage: plamenu account alias add [OPTIONS] <USERNAME> <ALIAS>

Arguments:
  <USERNAME>
  <ALIAS>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help
plamenu account alias list
$ plamenu account alias list --help
List an account's declared aliases

Usage: plamenu account alias list [OPTIONS] <USERNAME>

Arguments:
  <USERNAME>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help
plamenu account alias remove
$ plamenu account alias remove --help
Remove a declared alias (by its stored URI)

Usage: plamenu account alias remove [OPTIONS] <USERNAME> <ALIAS>

Arguments:
  <USERNAME>
  <ALIAS>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu account migrate

$ plamenu account migrate --help
Migrate this account to another (remote) account, re-pointing local followers and telling remote followers via `Move`. The destination must already list this account in its aliases

Usage: plamenu account migrate [OPTIONS] <USERNAME> <TARGET>

Arguments:
  <USERNAME>
  <TARGET>    Destination `user@domain` acct or actor URI

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu group

$ plamenu group --help
Manage local groups

Usage: plamenu group [OPTIONS] <COMMAND>

Commands:
  add       Create a local group owned by an existing local account
  list      List local groups
  lock      Lock a thread in a group — no new comments (moderator action)
  unlock    Reopen a locked thread
  remove    Remove a post or comment from a group (moderator removal; the status itself survives)
  ban       Ban an account from a group (outcast). `target` is a local username or a known `user@domain` handle
  unban     Lift a group ban
  rename    Rename a group (change its display name), preserving every other setting. Federates the profile Update
  transfer  Transfer ownership of a group to another local member. The previous owner is demoted to moderator
  delete    Delete a group: tombstones the actor (`410 Gone`), federates `Delete(Group)` (Lemmy) plus `Delete(Actor)` (Mastodon), and purges its content. Irreversible
  help      Print this message or the help of the given subcommand(s)

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu group add

$ plamenu group add --help
Create a local group owned by an existing local account

Usage: plamenu group add [OPTIONS] --owner <OWNER> <NAME>

Arguments:
  <NAME>  The group's name — its `preferredUsername`, sharing the local account namespace (`!name@domain` / `@name@domain`)

Options:
      --config <CONFIG>              TOML configuration file used by every command except `config generate` [default: plamenu.toml]
      --owner <OWNER>                Local username of the owner
      --display-name <DISPLAY_NAME>  [default: ""]
      --approval                     Hold join requests for moderator approval instead of auto-accepting followers
  -h, --help                         Print help

plamenu group list

$ plamenu group list --help
List local groups

Usage: plamenu group list [OPTIONS]

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu group lock

$ plamenu group lock --help
Lock a thread in a group — no new comments (moderator action)

Usage: plamenu group lock [OPTIONS] <GROUP> <STATUS_ID>

Arguments:
  <GROUP>      The group's name
  <STATUS_ID>  The thread root (or any post in it) as a local status id

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu group unlock

$ plamenu group unlock --help
Reopen a locked thread

Usage: plamenu group unlock [OPTIONS] <GROUP> <STATUS_ID>

Arguments:
  <GROUP>
  <STATUS_ID>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu group remove

$ plamenu group remove --help
Remove a post or comment from a group (moderator removal; the status itself survives)

Usage: plamenu group remove [OPTIONS] <GROUP> <STATUS_ID>

Arguments:
  <GROUP>
  <STATUS_ID>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
      --reason <REASON>  [default: "Removed by moderator"]
  -h, --help             Print help

plamenu group ban

$ plamenu group ban --help
Ban an account from a group (outcast). `target` is a local username or a known `user@domain` handle

Usage: plamenu group ban [OPTIONS] <GROUP> <TARGET>

Arguments:
  <GROUP>
  <TARGET>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu group unban

$ plamenu group unban --help
Lift a group ban

Usage: plamenu group unban [OPTIONS] <GROUP> <TARGET>

Arguments:
  <GROUP>
  <TARGET>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu group rename

$ plamenu group rename --help
Rename a group (change its display name), preserving every other setting. Federates the profile Update

Usage: plamenu group rename [OPTIONS] --display-name <DISPLAY_NAME> <GROUP>

Arguments:
  <GROUP>  The group's name

Options:
      --config <CONFIG>              TOML configuration file used by every command except `config generate` [default: plamenu.toml]
      --display-name <DISPLAY_NAME>
  -h, --help                         Print help

plamenu group transfer

$ plamenu group transfer --help
Transfer ownership of a group to another local member. The previous owner is demoted to moderator

Usage: plamenu group transfer [OPTIONS] --to <TO> <GROUP>

Arguments:
  <GROUP>  The group's name

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
      --to <TO>          Local username (or `@user`) of the new owner — must be a member
  -h, --help             Print help

plamenu group delete

$ plamenu group delete --help
Delete a group: tombstones the actor (`410 Gone`), federates `Delete(Group)` (Lemmy) plus `Delete(Actor)` (Mastodon), and purges its content. Irreversible

Usage: plamenu group delete [OPTIONS] <GROUP>

Arguments:
  <GROUP>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu post

$ plamenu post --help
Post a status as a local account (enqueued for follower delivery)

Usage: plamenu post [OPTIONS] <USERNAME> <TEXT>

Arguments:
  <USERNAME>
  <TEXT>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu follow

$ plamenu follow --help
Follow a remote account (`user@domain`) as a local account

Usage: plamenu follow [OPTIONS] <USERNAME> <TARGET>

Arguments:
  <USERNAME>
  <TARGET>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu emoji

$ plamenu emoji --help
Manage this instance's custom emoji

Usage: plamenu emoji [OPTIONS] <COMMAND>

Commands:
  add     Add a local custom emoji from an image file (PNG, GIF or WebP, at most 256 KB)
  list    List local custom emoji
  remove  Remove a local custom emoji by shortcode
  help    Print this message or the help of the given subcommand(s)

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu emoji add

$ plamenu emoji add --help
Add a local custom emoji from an image file (PNG, GIF or WebP, at most 256 KB)

Usage: plamenu emoji add [OPTIONS] <SHORTCODE> <FILE>

Arguments:
  <SHORTCODE>  The `:shortcode:` (without colons): 2-128 letters, digits or `_`
  <FILE>       Path to the image file

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu emoji list

$ plamenu emoji list --help
List local custom emoji

Usage: plamenu emoji list [OPTIONS]

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu emoji remove

$ plamenu emoji remove --help
Remove a local custom emoji by shortcode

Usage: plamenu emoji remove [OPTIONS] <SHORTCODE>

Arguments:
  <SHORTCODE>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu role

$ plamenu role --help
Inspect moderation roles

Usage: plamenu role [OPTIONS] <COMMAND>

Commands:
  list  List the available roles and their permission bitmasks
  help  Print this message or the help of the given subcommand(s)

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu role list

$ plamenu role list --help
List the available roles and their permission bitmasks

Usage: plamenu role list [OPTIONS]

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu rule

$ plamenu rule --help
Manage this instance's published rules (server policies)

Usage: plamenu rule [OPTIONS] <COMMAND>

Commands:
  list    List the published rules in display order
  add     Add a rule. New rules are appended after the existing ones
  edit    Edit an existing rule's text and/or hint by id
  remove  Remove a rule by id (soft-deleted; reports that cite it still resolve)
  help    Print this message or the help of the given subcommand(s)

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu rule list

$ plamenu rule list --help
List the published rules in display order

Usage: plamenu rule list [OPTIONS]

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu rule add

$ plamenu rule add --help
Add a rule. New rules are appended after the existing ones

Usage: plamenu rule add [OPTIONS] <TEXT>

Arguments:
  <TEXT>  The rule text shown to users (max 300 characters)

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
      --hint <HINT>      Optional longer explanation shown beneath the rule [default: ""]
  -h, --help             Print help

plamenu rule edit

$ plamenu rule edit --help
Edit an existing rule's text and/or hint by id

Usage: plamenu rule edit [OPTIONS] <ID>

Arguments:
  <ID>

Options:
      --config <CONFIG>      TOML configuration file used by every command except `config generate` [default: plamenu.toml]
      --text <TEXT>
      --hint <HINT>
      --priority <PRIORITY>  Reposition the rule in the ordered list
  -h, --help                 Print help

plamenu rule remove

$ plamenu rule remove --help
Remove a rule by id (soft-deleted; reports that cite it still resolve)

Usage: plamenu rule remove [OPTIONS] <ID>

Arguments:
  <ID>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu announcement

$ plamenu announcement --help
Manage server announcements (shown to logged-in users)

Usage: plamenu announcement [OPTIONS] <COMMAND>

Commands:
  list       List all announcements (published and not), newest first
  add        Add an announcement. It publishes immediately unless `--scheduled-at` is set to a future RFC 3339 timestamp
  publish    Publish an announcement by id
  unpublish  Unpublish an announcement by id
  remove     Remove an announcement by id (also clears its reactions and dismissals)
  help       Print this message or the help of the given subcommand(s)

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu announcement list

$ plamenu announcement list --help
List all announcements (published and not), newest first

Usage: plamenu announcement list [OPTIONS]

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu announcement add

$ plamenu announcement add --help
Add an announcement. It publishes immediately unless `--scheduled-at` is set to a future RFC 3339 timestamp

Usage: plamenu announcement add [OPTIONS] <TEXT>

Arguments:
  <TEXT>  The announcement text (linkified; supports @mentions and #hashtags)

Options:
      --config <CONFIG>              TOML configuration file used by every command except `config generate` [default: plamenu.toml]
      --scheduled-at <SCHEDULED_AT>  Hold the announcement unpublished until this time (RFC 3339)
  -h, --help                         Print help

plamenu announcement publish

$ plamenu announcement publish --help
Publish an announcement by id

Usage: plamenu announcement publish [OPTIONS] <ID>

Arguments:
  <ID>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu announcement unpublish

$ plamenu announcement unpublish --help
Unpublish an announcement by id

Usage: plamenu announcement unpublish [OPTIONS] <ID>

Arguments:
  <ID>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu announcement remove

$ plamenu announcement remove --help
Remove an announcement by id (also clears its reactions and dismissals)

Usage: plamenu announcement remove [OPTIONS] <ID>

Arguments:
  <ID>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu media

$ plamenu media --help
Maintenance tasks for stored media

Usage: plamenu media [OPTIONS] <COMMAND>

Commands:
  backfill-sizes  Fill in missing stored byte sizes for media, avatars, headers and emoji (backing the admin storage metrics). Stats each file on the media store; skips any whose file is missing
  reconcile       Find stored files no database row references — orphans left by deletions that ran before the durable cleanup queue existed — and (with `--delete`) schedule them for removal. Reports counts only by default. Run during low upload activity: a brand-new upload whose row is not yet inserted would otherwise look orphaned
  help            Print this message or the help of the given subcommand(s)

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu media backfill-sizes

$ plamenu media backfill-sizes --help
Fill in missing stored byte sizes for media, avatars, headers and emoji (backing the admin storage metrics). Stats each file on the media store; skips any whose file is missing

Usage: plamenu media backfill-sizes [OPTIONS]

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu media reconcile

$ plamenu media reconcile --help
Find stored files no database row references — orphans left by deletions that ran before the durable cleanup queue existed — and (with `--delete`) schedule them for removal. Reports counts only by default. Run during low upload activity: a brand-new upload whose row is not yet inserted would otherwise look orphaned

Usage: plamenu media reconcile [OPTIONS]

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
      --delete           Actually enqueue the orphans for deletion (default: report only)
  -h, --help             Print help

plamenu federation

$ plamenu federation --help
Read-only federation diagnostics (fetch objects, resolve handles, inspect the delivery queue and the reachability breaker)

Usage: plamenu federation [OPTIONS] <COMMAND>

Commands:
  fetch         Fetch a remote `ActivityPub` object and pretty-print it. Signed GET, following a permalink to the canonical `id` (paste a status URL or an actor URL). Nothing is stored
  webfinger     Resolve a `user@domain` handle via `WebFinger` and print every `ActivityPub` actor it advertises. Nothing is stored (unlike a real follow, no remote account row is created)
  queue         Inspect the outbound delivery queue
  reachability  List the hosts the delivery breaker currently considers unreachable, with their failure streak and last error
  keys          Inspect, rotate, revoke, or rewrap normalized federation keys
  help          Print this message or the help of the given subcommand(s)

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu federation fetch

$ plamenu federation fetch --help
Fetch a remote `ActivityPub` object and pretty-print it. Signed GET, following a permalink to the canonical `id` (paste a status URL or an actor URL). Nothing is stored

Usage: plamenu federation fetch [OPTIONS] <URL>

Arguments:
  <URL>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu federation webfinger

$ plamenu federation webfinger --help
Resolve a `user@domain` handle via `WebFinger` and print every `ActivityPub` actor it advertises. Nothing is stored (unlike a real follow, no remote account row is created)

Usage: plamenu federation webfinger [OPTIONS] <ACCT>

Arguments:
  <ACCT>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu federation queue

$ plamenu federation queue --help
Inspect the outbound delivery queue

Usage: plamenu federation queue [OPTIONS] <COMMAND>

Commands:
  inspect  Summarize the queue: total/due counts, when the next job fires, and the worst per-host backlogs
  help     Print this message or the help of the given subcommand(s)

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help
plamenu federation queue inspect
$ plamenu federation queue inspect --help
Summarize the queue: total/due counts, when the next job fires, and the worst per-host backlogs

Usage: plamenu federation queue inspect [OPTIONS]

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu federation reachability

$ plamenu federation reachability --help
List the hosts the delivery breaker currently considers unreachable, with their failure streak and last error

Usage: plamenu federation reachability [OPTIONS]

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu federation keys

$ plamenu federation keys --help
Inspect, rotate, revoke, or rewrap normalized federation keys

Usage: plamenu federation keys [OPTIONS] <COMMAND>

Commands:
  audit            Fail unless all private rows are encrypted, decryptable, and match their public halves
  rewrap           Re-encrypt rows from configured previous secrets onto the primary encryption-secret version. Safe to resume
  contract         Permanently drop the verified-empty legacy plaintext columns after all processes have been upgraded and the rollback window has ended
  rotate-account   Rotate one local account signing algorithm with a bounded overlap
  rotate-instance  Rotate an instance-actor signing algorithm with a bounded overlap
  revoke           Immediately revoke a key URI; it can no longer sign or verify
  retire           Retire a key URI after its overlap/grace use is complete
  help             Print this message or the help of the given subcommand(s)

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help
plamenu federation keys audit
$ plamenu federation keys audit --help
Fail unless all private rows are encrypted, decryptable, and match their public halves

Usage: plamenu federation keys audit [OPTIONS]

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help
plamenu federation keys rewrap
$ plamenu federation keys rewrap --help
Re-encrypt rows from configured previous secrets onto the primary encryption-secret version. Safe to resume

Usage: plamenu federation keys rewrap [OPTIONS]

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
      --limit <LIMIT>    Process at most this many stale rows, for controlled rolling rehearsals. Omit to drain every bounded batch
  -h, --help             Print help
plamenu federation keys contract
$ plamenu federation keys contract --help
Permanently drop the verified-empty legacy plaintext columns after all processes have been upgraded and the rollback window has ended

Usage: plamenu federation keys contract [OPTIONS]

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help
plamenu federation keys rotate-account
$ plamenu federation keys rotate-account --help
Rotate one local account signing algorithm with a bounded overlap

Usage: plamenu federation keys rotate-account [OPTIONS] --algorithm <ALGORITHM> <USERNAME>

Arguments:
  <USERNAME>

Options:
      --algorithm <ALGORITHM>
          [possible values: rsa, ed25519]
      --config <CONFIG>
          TOML configuration file used by every command except `config generate` [default: plamenu.toml]
      --overlap-hours <OVERLAP_HOURS>
          [default: 168]
      --activation-delay-seconds <ACTIVATION_DELAY_SECONDS>
          Publish the replacement before using it to sign, giving peers time to process the old-key-signed Actor Update [default: 300]
  -h, --help
          Print help
plamenu federation keys rotate-instance
$ plamenu federation keys rotate-instance --help
Rotate an instance-actor signing algorithm with a bounded overlap

Usage: plamenu federation keys rotate-instance [OPTIONS] --algorithm <ALGORITHM>

Options:
      --algorithm <ALGORITHM>
          [possible values: rsa, ed25519]
      --config <CONFIG>
          TOML configuration file used by every command except `config generate` [default: plamenu.toml]
      --overlap-hours <OVERLAP_HOURS>
          [default: 168]
      --activation-delay-seconds <ACTIVATION_DELAY_SECONDS>
          [default: 300]
  -h, --help
          Print help
plamenu federation keys revoke
$ plamenu federation keys revoke --help
Immediately revoke a key URI; it can no longer sign or verify

Usage: plamenu federation keys revoke [OPTIONS] <KEY_URI>

Arguments:
  <KEY_URI>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help
plamenu federation keys retire
$ plamenu federation keys retire --help
Retire a key URI after its overlap/grace use is complete

Usage: plamenu federation keys retire [OPTIONS] <KEY_URI>

Arguments:
  <KEY_URI>

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

plamenu self-destruct

$ plamenu self-destruct --help
Erase the server from the federation (Mastodon's `tootctl self-destruct`): broadcast account deletion notices to every known server, and serve 410 Gone while they go out. Irreversible; always asks for confirmation. Re-run to see the wind-down's progress

Usage: plamenu self-destruct [OPTIONS]

Options:
      --config <CONFIG>  TOML configuration file used by every command except `config generate` [default: plamenu.toml]
  -h, --help             Print help

Clients and APIs

Plamenu provides a Mastodon-compatible REST API, OAuth 2, WebSocket streaming, Web Push, and an experimental Lemmy /api/v3 adapter.

Sign in from an app

Enter your server’s HTTPS hosting domain in a Mastodon-compatible client. The app opens Plamenu’s authorization page, where you sign in and approve access. On a split-domain installation, use the hosting domain (for example, social.example.com), even if your handle ends in @example.com.

Build a client

Test authentication, timelines, posting, uploads, and notifications against the exact Plamenu version you intend to support. Plamenu-specific APIs may change incompatibly during the experimental release series.

Identity statements

FEP-c390 uses signed statements to link an actor to a user-owned key. These Plamenu endpoints are separate from the retired Mastodon Keybase API:

  • GET /api/v1/accounts/:id/identity_statements: original verified JSON statements.
  • POST /api/v1/accounts/identity_statements: publish a signed statement as the JSON body.
  • DELETE /api/v1/accounts/identity_statements: remove one with {"subject":"did:key:…"}.

Writes require OAuth write:accounts and return the remaining statements. Publishing replaces a statement with the same subject; up to ten are allowed, with a 16 KiB limit per document. Invalid statements return 422. Both writes send an actor Update to connected peers.

Supported proofs use Ed25519 did:key subjects, DataIntegrityProof, assertionMethod, and eddsa-jcs-2022 (also its legacy name jcs-eddsa-2022). The verification method must be the subject DID or its canonical #z6Mk… fragment. The statement must name the exact actor ID in alsoKnownAs. Expired proofs are excluded from responses. Ethereum and legacy prehashed Minisign proofs are not supported.

OAuth for client developers

Use the authorization-code flow to connect a member’s account. The member signs in and approves access on Plamenu; your client receives a bearer token for API requests.

Use the server’s HTTPS hosting domain for every endpoint below. On a split-domain installation this may differ from the domain in account handles. Replace the example domains and uppercase placeholders with your own values.

Register your application

Registration does not require authentication. Register separately on each server:

curl --request POST https://social.example.com/api/v1/apps \
  --data-urlencode 'client_name=Example client' \
  --data-urlencode 'redirect_uris=https://client.example/callback' \
  --data-urlencode 'scopes=profile read:statuses write:statuses'

Save client_id and client_secret from the JSON response. The secret is returned only at creation. Plamenu requires both values in the POST body when exchanging a code or revoking a token, including when using PKCE. HTTP Basic client authentication and exchanges without a client secret are not supported.

redirect_uris accepts newline-separated URIs in a form request or an array in a JSON request. Use an HTTPS callback for a web client; native clients can register a custom URI scheme. Redirect URIs must match exactly at authorization and token exchange, including any path, port, and query string. Fragments are not allowed.

For a manual command-line flow, register urn:ietf:wg:oauth:2.0:oob as the redirect URI. Plamenu then displays the code in the browser for the member to copy.

Request authorization

Generate a fresh PKCE verifier and state for each attempt. Keep both in the client’s pending login session. PKCE is optional for compatibility with older clients; when used, its only supported method is S256.

This Python example generates the values and authorization URL:

import base64
import hashlib
import secrets
from urllib.parse import urlencode

verifier = secrets.token_urlsafe(32)
challenge = base64.urlsafe_b64encode(
    hashlib.sha256(verifier.encode("ascii")).digest()
).rstrip(b"=").decode("ascii")
state = secrets.token_urlsafe(32)

parameters = {
    "response_type": "code",
    "client_id": "CLIENT_ID",
    "redirect_uri": "https://client.example/callback",
    "scope": "profile read:statuses write:statuses",
    "state": state,
    "code_challenge": challenge,
    "code_challenge_method": "S256",
}
authorization_url = "https://social.example.com/oauth/authorize?" + urlencode(parameters)
print("PKCE verifier:", verifier)
print("State:", state)
print("Authorization URL:", authorization_url)

Open that URL in the member’s browser. After approval, Plamenu redirects to your callback with code and the original state. Reject a callback with missing or mismatched state. If the member declines, the callback contains error=access_denied and state.

Scopes are space-separated. Request only what the client needs, within the scopes registered for the application. This example uses profile to identify the signed-in account, read:statuses to read timelines, and write:statuses to publish posts. Add write:media to both requests if the client uploads files. A broad read or write grant covers its granular scopes; a granular grant covers only that resource. Omitting scope at authorization requests read, which must be covered by the registration; it does not select all registered scopes.

Exchange the code

Codes expire after ten minutes and can be used only once. Send the verifier from the same authorization attempt, and the same redirect URI:

curl --request POST https://social.example.com/oauth/token \
  --data-urlencode 'grant_type=authorization_code' \
  --data-urlencode 'client_id=CLIENT_ID' \
  --data-urlencode 'client_secret=CLIENT_SECRET' \
  --data-urlencode 'redirect_uri=https://client.example/callback' \
  --data-urlencode 'code=AUTHORIZATION_CODE' \
  --data-urlencode 'code_verifier=PKCE_VERIFIER'

The response contains access_token, token_type (Bearer), scope (the granted space-separated scopes), and created_at (Unix seconds). There is no refresh_token or expires_in: API tokens have no fixed expiry. They can still be revoked, and account or server restrictions can prevent their use.

Use and revoke the token

Set ACCESS_TOKEN in your environment and send it in the Authorization header:

curl https://social.example.com/api/v1/accounts/verify_credentials \
  --header "Authorization: Bearer $ACCESS_TOKEN"

This returns the member’s account, including its id and username. Store tokens in the client’s credential storage and keep them out of URLs and logs.

Revoke the token when disconnecting the account:

curl --request POST https://social.example.com/oauth/revoke \
  --data-urlencode 'client_id=CLIENT_ID' \
  --data-urlencode 'client_secret=CLIENT_SECRET' \
  --data-urlencode 'token=ACCESS_TOKEN'

A successful request returns HTTP 200 with {}, including when the token is already revoked or unknown. Members can also revoke access in their account settings.

Other grants and errors

/oauth/token also accepts grant_type=client_credentials with client_id, client_secret, and an optional scope. It creates an application token with no member attached, so it cannot read a member’s home timeline or post on their behalf. An omitted scope uses the application’s registered scopes. Password and refresh-token grants are not supported.

ErrorWhat to check
invalid_clientSend the client ID and secret from this server in the POST body.
invalid_scopeRequested scopes must be recognized and covered by the application’s registration.
invalid_grantCheck code age, exact redirect URI, application, and PKCE verifier. Start a new authorization attempt; a failed exchange may have consumed the code.
unsupported_grant_typeUse authorization_code or client_credentials.

For API requests, a 401 can mean the token is no longer valid; let the member authorize again. A 403 can mean insufficient scopes, account restrictions, or server policy. Check the response’s error field before retrying.

Connect a Lemmy client

Plamenu provides a Lemmy-compatible API at /api/v3, targeting the lemmy-js-client 0.19.11 contract. NodeInfo identifies the server as plamenu. A client that chooses its API from that value must recognize Plamenu or offer manual backend selection. Photon needs this recognition change.

Supported operations

AreaOperations
AccountsLogin, registration, password and profile changes, account deletion
DiscoverySite information, people, search, and object resolution
CommunitiesList, create, edit, follow, block, and moderate
Posts and commentsList, create, edit, delete, vote, and save
InboxReplies, mentions, private messages, unread counts, and read state
ReportsPost/comment reports, moderator listing, and resolution
MediaPictrs upload, retrieval, and deletion; account and admin media lists
Custom emojiCreate, edit, and delete
AdministrationSite settings, registration approval, suspension, administrator roles, and purges

These operations use Plamenu’s permissions, visibility, and federation rules. Errors use Lemmy’s { "error": "..." } format. Requests for settings with no Plamenu equivalent are rejected.

Differences that affect clients

  • Entity IDs are stable signed 32-bit aliases. Native Plamenu IDs and ActivityPub URLs remain unchanged; clients must use the IDs returned by /api/v3.
  • Account deletion requires delete_content=true. Restore requests for deleted or removed content are unsupported.
  • Editing a post cannot change its external URL or custom thumbnail.
  • Post lists use chronological Plamenu timelines for every requested Lemmy sort value. Lemmy ranking modes are not implemented.
  • Private messages are Plamenu direct conversations. Changing one message’s read state changes the state reported for its conversation.
  • The modlog API, private-message reports, and some notification, language, flair, poll, metadata, and vote-list APIs are not implemented.

Testing a client

Router tests are in crates/server/tests/lemmy_api.rs. They cover account, content, media, moderation, and ID round trips, including persistence across a router restart. Run them with:

./dev test -p plamenu --test lemmy_api

For client testing, check backend selection first, then login, list/detail navigation, posting and editing, inbox state, and any moderation actions the client offers. Confirm the resulting state in Plamenu as well as the client’s response to API errors.

ActivityPub protocol and extensions

Plamenu uses WebFinger and NodeInfo for discovery, ActivityPub actor and object endpoints, signed inbox delivery, and signed or authorized fetch.

Identity and transport

Local actor and object URLs use the configured domain, which must stay fixed after federation begins. An optional account_domain provides shorter handles; its WebFinger, host-meta, and NodeInfo discovery paths must forward to Plamenu. Remote handle changes are accepted when WebFinger resolves to the same actor URI.

Transport supports draft-cavage and RFC 9421 HTTP signatures, FEP-8b32 integrity proofs, delivery retries, and URL/IP checks on remote fetches. Remote RSA, Ed25519, and ML-DSA-44 verification keys are stored per actor. Local RSA and Ed25519 private keys are encrypted at rest.

See signing-key operations for rotation and recovery, queues for retry behavior, and Tor/I2P transport for optional proxy routing.

Identity proofs

FEP-c390 statements are published in actor attachments and verified on remote actor fetches and Updates. Plamenu supports Ed25519 did:key with eddsa-jcs-2022 and its legacy suite name. It retains valid original statements and discards invalid or removed ones. Expired proofs are excluded when profiles or actors are read. This remains an experimental FEP; Ethereum and prehashed Minisign proofs are not implemented.

See member controls and client endpoints. Identity keys are user-owned and separate from the server’s federation signing keys.

Content

Local publishing includes Notes, polls, Articles, Events, and group threads. Titled ordinary group posts use Page. Replies, quotes, favourites, boosts, emoji reactions, group moderation, and event participation have federation handlers. Groups use experimental FEP-1b12/Lemmy-oriented behavior.

Group actors publish postingPolicy in the https://codefloe.com/plamenu/plamenu/ns# namespace: anyone, members, or mods. Lemmy’s postingRestrictedToMods is also emitted and is true for mods.

Outgoing Articles put the title in name and a heading in the body, supporting readers that display only the body. summary carries the content warning. Plamenu removes a matching leading title when rendering incoming articles.

Inbound object handling includes Note, Question, Article, Page, Event, Document, and Video, with remote media handling for audio/video and PeerTube streams. These are receiving and rendering capabilities; they do not imply that Plamenu provides a publishing tool for each remote object or media type. The posting guide describes local web controls.

Remote software controls its own presentation. Some clients display an article or event as a title and link. Fixture and live-peer coverage is described in interoperability testing.

Webxdc

The experimental FEP-752d implementation supports Note invitations, Group/WebxdcSession actors, membership, sequenced updates, fan-out, replay, participant removal, session closure, and deletion.

Bundles are publicly retrievable. Plamenu accepts application/webxdc+zip and legacy application/x-webxdc, checks the SHA-256 Multihash in digestMultibase, and validates archive limits before caching or execution. Serve bundles from the separate Webxdc origin described in the deployment configuration.

window.webxdc.joinRealtimeChannel() uses the draft’s ephemeral ActivityPub baseline: binary packets travel as Create/WebxdcEphemeral and coordinator Announce activities. Connected clients receive them over a host WebSocket. Packets have no durable serial, database storage, retry job, or replay, and expire within five seconds. This does not provide a low-latency guarantee.

Plamenu uses bounded in-memory fan-out, duplicate suppression, and delivery concurrency. Routing/authorization metadata is cached for two seconds and signing keys for thirty seconds; local Webxdc membership and lifecycle changes invalidate authorization immediately. Signed inbox requests still read known actor keys and instance policy. Packet processing skips durable inbox bookkeeping and unknown key refetch. Packet payloads never pass through the database notification bus. Run one serving process per instance for this experimental channel; replicas would need a shared, non-persistent fan-out transport. Reverse proxies should also exclude ephemeral inbox bodies from request logging.

Webxdc FEP draft


slug: “752d” authors: “lnkr lnkr@burning.homes” status: DRAFT dateReceived: 2026-08-15

FEP-752d: Federated Webxdc application sessions

Summary

This FEP maps [Webxdc] application sessions to ActivityPub. Each session has a Group actor that coordinates membership and redistributes participant activities using Announce. Durable updates form a serial log; ephemeral packets reach connected clients without storage or replay. Invitations are ordinary Note objects with HTTPS links, usable on non-supporting software.

The keywords MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are interpreted as specified by [RFC-2119] and [RFC-8174].

Session actor and bundle

A session is one shared application instance, identified by an HTTPS actor id. Its coordinator hosts that actor, admits participants, sequences durable updates, and redistributes traffic. A host runs the application and provides the Webxdc API for its user. Two sessions using identical bundle bytes still have independent membership, state, storage, and channels.

The session actor MUST:

  • include both Group and WebxdcSession in type;
  • expose inbox, outbox, and followers endpoints;
  • set attributedTo to its creator or controlling actor;
  • set webxdcProtocol to https://w3id.org/fep/752d;
  • identify exactly one immutable bundle as described below;
  • advertise sendUpdateInterval and sendUpdateMaxSize; and
  • authenticate its activities using the implementation’s ActivityPub authentication mechanism.

followers MUST contain exactly the actors with active, accepted, protocol-marked membership. Generic followers MUST NOT receive Webxdc traffic. The actor SHOULD serve an HTML landing page through content negotiation; it need not have a WebFinger address.

{
  "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/fep/752d"],
  "id": "https://social.example/webxdc/chess",
  "type": ["Group", "WebxdcSession"],
  "name": "Chess with Alice",
  "attributedTo": "https://social.example/users/alice",
  "inbox": "https://social.example/webxdc/chess/inbox",
  "outbox": "https://social.example/webxdc/chess/outbox",
  "followers": "https://social.example/webxdc/chess/followers",
  "webxdcProtocol": "https://w3id.org/fep/752d",
  "attachment": {
    "id": "https://social.example/webxdc/chess/bundle.xdc",
    "type": "Document",
    "mediaType": "application/webxdc+zip",
    "url": "https://social.example/webxdc/chess/bundle.xdc",
    "digestMultibase": "uEiA-nuhrWADMWAE3jnLwXnYB7ytEgnGqTS9dzxeyQeO04A"
  },
  "sendUpdateInterval": 10000,
  "sendUpdateMaxSize": 128000,
  "published": "2026-08-15T12:00:00Z"
}

The bundle attachment MUST be a Document with HTTPS id and url and a digestMultibase: a Multibase-encoded SHA-256 Multihash of the exact .xdc archive bytes [Data-Integrity]. Publishers SHOULD use application/webxdc+zip; consumers MUST also accept the deployed application/x-webxdc type. A filename suffix or arbitrary ZIP attachment alone does not identify an executable session. Hosts MUST verify the digest before extraction or execution.

The bundle identity, media type, digest, and bytes MUST remain unchanged for the session’s lifetime. Its retrieval URL MAY change if those properties remain the same. A new application version requires a new session actor.

Public bundle retrieval is the interoperable baseline. A private bundle MUST require authenticated membership, rather than possession of its URL. Its retrieval requires a mutually supported actor-authenticated HTTPS mechanism, which this FEP does not define. A coordinator MUST reject membership if it cannot supply the bundle to that host. Accepted hosts MUST be able to obtain the bundle for joining and resuming while the session remains open.

Invitations and discovery

An invitation MUST be an ordinary Create of a Note containing a human-readable HTTPS anchor to the session landing page. It SHOULD also set audience to the session actor and attach a Link with that URL, mediaType: "text/html", and rel: "https://w3id.org/fep/752d/open". The explicit anchor is required because generic clients may ignore attachments or extension fields.

{
  "type": "Note",
  "content": "<p>Play <a href=\"https://social.example/webxdc/chess\">chess with Alice</a>.</p>",
  "audience": "https://social.example/webxdc/chess",
  "attachment": {
    "type": "Link",
    "href": "https://social.example/webxdc/chess",
    "mediaType": "text/html",
    "rel": "https://w3id.org/fep/752d/open"
  }
}

Supporting hosts resolve and validate the actor before offering a trusted launch control. Discovery, previewing, or reading an invitation MUST NOT execute the bundle. Invitation visibility neither grants membership nor encrypts session content. Invitations are independent posts: copying, editing, or deleting one does not copy, alter, or end the session.

The landing page SHOULD show the application, coordinator, session state, and how to join. It MAY offer a coordinator-operated guest runtime or a remote interaction flow. Visiting the link does not authenticate a remote account; guest identities MUST be described as guests, and scoped to the session. Guest management and cross-server browser authentication are outside this FEP.

Membership and session lifetime

Session lifetime is independent of an application’s process or browser window. An open session persists when nobody is running the app. Closing a window, disconnecting, or leaving the ephemeral channel does not end membership or the session. Application-local storage is disposable cache; the durable log is the shared state used to join or resume.

Join and resume

A host joins by sending an authenticated Follow to the session actor, with object, context, audience, and to identifying that actor and webxdcProtocol: "https://w3id.org/fep/752d". Generic follows MUST be rejected. The coordinator checks session state, actor/domain blocks, admission policy, and resource limits, then responds with Accept or Reject referencing that Follow or its identifier. HTTP success alone is not membership acceptance.

{
  "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/fep/752d"],
  "id": "https://remote.example/users/bob/follows/chess-1",
  "type": "Follow",
  "actor": "https://remote.example/users/bob",
  "object": "https://social.example/webxdc/chess",
  "to": "https://social.example/webxdc/chess",
  "context": "https://social.example/webxdc/chess",
  "audience": "https://social.example/webxdc/chess",
  "webxdcProtocol": "https://w3id.org/fep/752d"
}

An Accept MUST be authenticated as the session actor, addressed to the participant, and include audience, webxdcProtocol, and webxdcMaxSerial. The latter is the greatest durable serial assigned when acceptance commits, or zero for an empty log. The host MUST match it to its pending Follow.

{
  "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/fep/752d"],
  "id": "https://social.example/webxdc/chess/activities/accept-bob",
  "type": "Accept",
  "actor": "https://social.example/webxdc/chess",
  "object": "https://remote.example/users/bob/follows/chess-1",
  "to": "https://remote.example/users/bob",
  "audience": "https://social.example/webxdc/chess",
  "webxdcProtocol": "https://w3id.org/fep/752d",
  "webxdcMaxSerial": 6
}

Acceptance and the replay boundary MUST be atomic: for boundary B, serials 1..B are replayed and every subsequent update includes the participant in live fan-out. The coordinator MUST retain the complete durable log while allowing new joins; inability to supply it requires rejecting new membership.

Hosts MUST NOT submit updates or packets before acceptance. Replay and live announcements may arrive before or after Accept, in any order. Hosts MUST NOT present the app as synchronized until all serials 1..B are available. Ephemeral packets arriving before acceptance or channel connection MUST be discarded, never held for later launch.

Reopening the app under an active membership resumes the same session, pseudonymous identity, and durable history. It does not require a fresh Follow. If cached history was discarded or synchronization cannot complete, the host MAY leave and join with a new Follow to obtain a new replay boundary. An access-controlled OrderedCollection of durable announcements MAY optimize retrieval, but does not replace required inbox replay without a separate agreement.

Leave and removal

A participant leaves with an authenticated Undo of its accepted Follow. The coordinator MAY remove a participant with an authenticated Remove, whose object is the participant, target is the session’s followers collection, and audience is the session. Removal MUST stop authorization for new traffic, future delivery, private retrieval, and connected ephemeral channels. A host MUST disable local participation as soon as it leaves or learns of removal. A new accepted Follow is required to rejoin; stale responses to an earlier membership MUST NOT reactivate it.

Leaving does not retract the participant’s accepted durable updates, which remain necessary to reconstruct shared state. Previously delivered data cannot be recalled. A host MAY discard its cached session when no local membership needs it. A coordinator MAY require the controlling user to close or delete the session instead of leaving it unmanaged; ownership transfer is not defined.

Close

An open session has no implicit expiry. A coordinator MAY close it under its published retention or administration policy, including inactivity limits. Closure MUST be represented by setting endTime on the session actor and sending an authenticated Update to current participants. Following [ActivityPub], object MUST contain the complete updated actor, including its immutable bundle metadata, endTime, and updated; it is not a partial patch.

Closure is terminal for that actor. The coordinator MUST reject new joins, durable submissions, and ephemeral packets. Hosts MUST stop sending and close ephemeral connections when they learn of closure. Delayed durable announcements already accepted before closure MAY still complete retained history. Read-only access to the landing page, bundle, or history MAY continue under local policy. Restarting play requires a new session actor; clearing endTime MUST NOT reopen an existing session.

Delete

A coordinator MAY delete an open or closed session with an authenticated Delete whose object identifies the session actor, optionally as a Tombstone with formerType: ["Group", "WebxdcSession"] and deleted. It MUST deliver this activity to the participant snapshot taken before purge.

Deletion terminates participation and removes the bundle, extracted files, durable log, memberships, and guest credentials held for that session. Receiving hosts MUST stop its runtimes and channels and purge their cached session data. A minimal tombstone MUST prevent delayed activities from recreating the deleted session; an HTTP representation SHOULD return 410 Gone. The tombstone need retain only the session identifier and deletion time. The coordinator MAY retain signing material and delivery metadata needed to finish sending Delete.

Deleting a session does not delete its invitation posts, other sessions using the same bundle, or copies exported by users. Servers cannot guarantee erasure from uncooperative recipients or disconnected devices.

Durable updates

Submission and validation

A host maps sendUpdate(update, descr) to Create of a WebxdcUpdate, addressed to the session actor. The deprecated description argument is not federated. The activity and object MUST have distinct, globally unique identifiers, the participant as actor/attributedTo, and the session as context and audience. webxdcUpdate contains the complete Webxdc update as a JSON literal (@type: @json), including payload and any optional or unknown API members. The payload may itself be any JSON value, including null [Webxdc-sendUpdate].

{
  "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/fep/752d"],
  "id": "https://remote.example/users/bob/activities/move-1",
  "type": "Create",
  "actor": "https://remote.example/users/bob",
  "to": "https://social.example/webxdc/chess",
  "context": "https://social.example/webxdc/chess",
  "audience": "https://social.example/webxdc/chess",
  "object": {
    "id": "https://remote.example/users/bob/updates/move-1",
    "type": "WebxdcUpdate",
    "attributedTo": "https://remote.example/users/bob",
    "context": "https://social.example/webxdc/chess",
    "audience": "https://social.example/webxdc/chess",
    "webxdcUpdate": {"payload": {"move": "e2e4"}, "summary": "Black to move"}
  }
}

sendUpdateInterval is a non-negative integer in milliseconds; sendUpdateMaxSize is a positive byte count. Hosts MUST expose these values to the app and enforce them before federation. Hosts SHOULD queue early calls in order. The size limit measures the UTF-8 compact JSON serialization of the complete update, excluding the ActivityPub envelope. Coordinators MUST allow sufficient envelope overhead and serialization margin to honor their advertised limit; member ordering and insignificant whitespace must not invalidate an otherwise conforming update. Separate abuse and storage quotas are permitted.

The coordinator MUST authenticate the submitting actor, check active membership and session state, validate both envelopes and the Webxdc update, and enforce size, rate, and moderation limits. href MUST be an in-app relative reference. Human-readable fields MUST be treated as untrusted text. Invalid submissions MUST NOT be sequenced or redistributed; the coordinator MAY send Reject referencing the Create.

Sequencing, delivery, and replay

For each accepted update the coordinator atomically assigns the next contiguous positive webxdcSerial, starting at 1, and stores the unchanged Create. Serials MUST NOT exceed JavaScript’s maximum safe integer, 9007199254740991. It wraps the Create in an Announce with the session actor as actor, the session as audience, its followers collection as to, and the assigned serial.

The coordinator MUST resolve recipients and deliver the announcement to every active participant, including the sender, using ordinary durable ActivityPub delivery. Merely addressing a collection is insufficient. The sender’s echoed announcement, rather than HTTP acceptance of its submission, establishes the coordinator’s acceptance.

Hosts MUST apply durable updates only inside authenticated announcements from the expected coordinator. Direct participant-to-participant updates are not authoritative. The coordinator’s wrapper vouches for the embedded activity; it does not prove end-to-end participant authorship.

The coordinator MUST map both accepted input identifiers to their serial and announcement. Identical redelivery MUST NOT allocate another serial; identifier reuse with different content or a different counterpart identifier MUST be rejected. Hosts MUST deduplicate (session, serial) and treat conflicting contents at one serial as an integrity failure. Accepted updates are append-only: Update, Delete, or Undo MUST NOT mutate individual entries. A coordinator unable to retain the required history SHOULD close the session.

setUpdateListener(callback, serial) delivers locally known updates after serial in increasing serial order, then resolves its promise and continues with new updates [Webxdc-setUpdateListener]. The callback receives the preserved update with serial from the coordinator and max_serial equal to the highest locally known serial. Hosts MUST tolerate reordered and delayed federation, maintain the acceptance-boundary completeness check, and SHOULD deliver a contiguous prefix when possible. Applications must still handle concurrent changes; the coordinator’s order is not distributed consensus.

Ephemeral channel

Transport and semantics

This FEP treats Webxdc’s “realtime” API as an ephemeral channel: an ActivityPub-compatible baseline, without a low-latency guarantee. It is expected to underperform in latency-sensitive applications such as competitive reaction games. Lower-latency approaches, including direct client connections to the coordinator or overlay networks, require negotiation of other transport profiles; they do not use ActivityPub as packet transport and are outside this FEP.

Coordinators implementing this FEP MUST support the ephemeral packet envelope. Hosts MAY omit the experimental joinRealtimeChannel() API; when exposed over this baseline, it MUST follow [Webxdc-Realtime]. Membership authorizes the channel; opening or leaving it is local connection state and requires no new federated membership activity.

A packet follows the same participant → coordinator → participant-host path as a durable update, using Create and Announce, but MUST NOT receive a serial. Implementations MUST NOT persist packet payloads or envelopes to databases, activity archives, inbox/outbox collections, retry jobs, or application logs. They MUST NOT replay packets or retry failed delivery. Bounded, short-lived memory buffers and duplicate-ID caches are permitted. Implementations SHOULD cache routing and authorization metadata to avoid per-packet database work; membership, signing, and moderation checks still apply.

Only clients with a currently joined channel may receive packets. A receiving host MUST discard packets if there is no eligible connected client. It MUST NOT retain them for a later connection, listener registration, acceptance, or app launch. Slow consumers and overloaded hosts MAY drop packets. Packet delivery has no acknowledgment, ordering, completeness, or fairness guarantee. The originating participant MUST NOT receive its own packet as an echo.

Envelope

A participant sends a Create with a WebxdcEphemeral object. The activity MUST have a globally unique id, the authenticated participant as actor, the session as to, context, and audience, and the protocol marker. Its object MUST have a globally unique id, the same participant as attributedTo, the session as context, and webxdcData containing standard, padded RFC 4648 Base64 of the binary packet (not a data URL). The decoded size MUST NOT exceed 128000 bytes; an empty packet is valid.

published and endTime on the activity are required RFC 3339 timestamps. endTime MUST be later than published and no more than five seconds after it. Each hop MUST discard expired packets and bound any in-memory wait or network attempt by the remaining lifetime. Receivers MUST reject publication times more than one second in the future; this small clock tolerance is not a latency promise. Implementations need reasonably synchronized clocks.

{
  "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/fep/752d"],
  "id": "https://remote.example/users/bob/ephemeral/1",
  "type": "Create",
  "actor": "https://remote.example/users/bob",
  "to": "https://social.example/webxdc/chess",
  "context": "https://social.example/webxdc/chess",
  "audience": "https://social.example/webxdc/chess",
  "webxdcProtocol": "https://w3id.org/fep/752d",
  "published": "2026-08-15T12:05:00Z",
  "endTime": "2026-08-15T12:05:05Z",
  "object": {
    "id": "https://remote.example/users/bob/ephemeral/1#packet",
    "type": "WebxdcEphemeral",
    "attributedTo": "https://remote.example/users/bob",
    "context": "https://social.example/webxdc/chess",
    "webxdcData": "AAEC/w=="
  }
}

After validating current membership, session state, limits, and envelope, the coordinator wraps the unchanged Create in an Announce. The wrapper MUST have its own unique id, the session actor as actor, the protocol marker, the session as audience, and to equal to its followers collection. It MUST copy published and endTime unchanged and MUST NOT include webxdcSerial. It attempts direct authenticated inbox delivery to participant hosts; hosts without connected clients discard it. Multiple recipients sharing one inbox SHOULD receive a single delivery. No federated presence protocol is required.

A remote host MUST authenticate the outer activity as its expected coordinator and validate the embedded packet and unchanged deadline. It MUST NOT apply a direct participant packet or dereference a missing packet body. HTTP delivery MUST be signed by the activity’s actor; general relay/refetch fallback does not apply to these non-retrievable activities. Unknown keys require ordinary actor refresh outside the packet path; losing such packets is allowed.

Duplicate suppression SHOULD use a bounded in-memory (session, Create.id) cache lasting at least until expiry. Suppression may be lost on restart; there is no exactly-once guarantee. Neither duplicate detection nor rejection should produce durable per-packet records. Oversized frames, malformed Base64, cross-session addressing, unauthorized senders, and expired packets MUST be dropped or rejected before redistribution.

Host API

joinRealtimeChannel() synchronously returns a channel with setListener, send, and leave. A second join before leaving MUST throw. setListener replaces the callback, which receives a Uint8Array; send accepts a Uint8Array up to 128000 bytes. Sending while disconnected may silently lose the packet. Hosts MUST NOT buffer those sends for reconnection. leave invalidates that channel object; rejoining creates a fresh connection without old packets. Closing the runtime releases the channel without undoing session membership.

A host MUST enforce the same limits at the trusted bridge and transport boundary, independently of checks in app-visible JavaScript. It MUST stop traffic and invalidate connections on leave, removal, closure, or deletion. Hosts SHOULD apply bounded connection, packet-rate, byte-rate, and concurrent federation limits. Ephemeral overload MUST NOT enqueue work on the durable update path. Apps should put recoverable shared state in durable updates.

Runtime, privacy, and security

Hosts MUST implement the relevant [Webxdc] container and host API requirements. selfAddr MUST be a stable session-scoped pseudonym for the participant across devices and restarts, and SHOULD be unlinkable across sessions. Actor IDs, email addresses, signing keys, and credentials MUST NOT be exposed as selfAddr. A persisted random mapping or stable keyed derivation is suitable. selfName is a display name visible to the app; hosts should disclose this.

Bundles are untrusted executable content. Hosts MUST require explicit launch, isolate storage and execution by session, deny app-initiated Internet access, and keep account credentials outside the runtime. Browser hosts SHOULD use a dedicated uncredentialed origin per session and an authenticated MessageChannel to the trusted parent. CSP is defense in depth, not a complete network sandbox. A runtime that cannot prevent app-initiated network access does not meet Webxdc’s isolation requirement and MUST disclose that limitation.

Server-side fetches MUST enforce HTTPS, SSRF and redirect policy, digest verification, and time/size limits. Archive extraction MUST bound compressed and expanded sizes, compression ratio, file count, nesting, and path lengths, and reject traversal, absolute paths, symlinks, special files, and duplicate or ambiguously normalized paths. Bundle integrity establishes byte identity, not application safety or provenance.

Hosts MUST validate bridge origin/channel, session, method, type, and limits. App-supplied text MUST be rendered as text; href must stay inside the package. Notification text and rates require validation. sendToChat, exports, and external URL opening MUST pass through explicit trusted user review before posting or leaving the sandbox. No automatic ActivityPub mapping for sendToChat or file import is defined here.

The coordinator controls membership, order, availability, and retention. It can read or censor traffic and knows participants’ actor identities. HTTPS and restricted addressing do not provide end-to-end encryption. A private participant collection SHOULD require authorization. Guests, invite tokens, and bundle access MUST NOT treat a copied invitation URL as account identity.

Implementations MUST apply their actor/domain moderation policies to both channels and invalidate cached authorization when membership or session state changes. Packet bodies SHOULD be excluded from reverse-proxy and diagnostic logging as well as application storage. Multi-process hosts need an ephemeral fan-out mechanism or routing that reaches their connected clients; a persistent broker is not a conforming packet store.

Implementations SHOULD ship the protected JSON-LD context and MUST NOT allow remote contexts to redefine these terms. Plain-JSON implementations MUST recognize the exact compact names and preserve application JSON literally. Unknown properties do not authorize another transport.

Vocabulary and context

The namespace is https://w3id.org/fep/752d/ [FEP-888d]. The context below is also supplied as context.jsonld for publication at https://w3id.org/fep/752d.

TermMeaning
WebxdcSessionAdditional type of the session Group actor
WebxdcUpdateDurable Webxdc update object
WebxdcEphemeralNon-persistent binary packet object
webxdcProtocolProtocol IRI on session and control activities
webxdcUpdateComplete Webxdc update as a JSON literal
webxdcDataStandard Base64 packet data
webxdcSerialPositive coordinator-assigned durable serial
webxdcMaxSerialNon-negative acceptance replay boundary
sendUpdateIntervalMinimum durable update interval, milliseconds
sendUpdateMaxSizeMaximum serialized durable update bytes
{
  "@context": {
    "@version": 1.1,
    "@protected": true,
    "WebxdcSession": "https://w3id.org/fep/752d/WebxdcSession",
    "WebxdcUpdate": "https://w3id.org/fep/752d/WebxdcUpdate",
    "WebxdcEphemeral": "https://w3id.org/fep/752d/WebxdcEphemeral",
    "webxdcProtocol": {"@id": "https://w3id.org/fep/752d/webxdcProtocol", "@type": "@id"},
    "webxdcUpdate": {"@id": "https://w3id.org/fep/752d/webxdcUpdate", "@type": "@json"},
    "webxdcData": "https://w3id.org/fep/752d/webxdcData",
    "webxdcSerial": {"@id": "https://w3id.org/fep/752d/webxdcSerial", "@type": "http://www.w3.org/2001/XMLSchema#positiveInteger"},
    "webxdcMaxSerial": {"@id": "https://w3id.org/fep/752d/webxdcMaxSerial", "@type": "http://www.w3.org/2001/XMLSchema#nonNegativeInteger"},
    "sendUpdateInterval": {"@id": "https://w3id.org/fep/752d/sendUpdateInterval", "@type": "http://www.w3.org/2001/XMLSchema#nonNegativeInteger"},
    "sendUpdateMaxSize": {"@id": "https://w3id.org/fep/752d/sendUpdateMaxSize", "@type": "http://www.w3.org/2001/XMLSchema#positiveInteger"},
    "digestMultibase": "https://w3id.org/security#digestMultibase"
  }
}

Scope and implementations

An invitation-only publisher need implement only the ordinary linked Note. A session coordinator implements membership, lifecycle, durable replay, and ephemeral redistribution. A host implements membership, durable consumption, and isolated execution; its experimental ephemeral API remains optional. Client REST APIs, guest identity formats, user-interface design, alternative transport profiles, end-to-end encryption, snapshots/compaction, application signatures, and coordinator migration are outside this FEP.

Plamenu implements this draft experimentally. Its browser compatibility runtime isolates account credentials and session origins but does not claim complete Webxdc network isolation. Independent interoperability has not been established.

References

CC0 1.0 Universal (CC0 1.0) Public Domain Dedication

To the extent possible under law, the authors of this Fediverse Enhancement Proposal have waived all copyright and related or neighboring rights to this work.

Interoperability testing

Rust integration tests in crates/server/tests/ exercise the router, database, delivery queue, signatures, and serialization using pinned ActivityPub fixtures. The Python suite in e2e/ tests HTTPS exchanges with running peer servers.

The Python E2E suite is currently tied to one developer’s machine and an ad hoc local setup. It is not portable or expected to be usable elsewhere as provided; it is included for transparency and as examples. For release builds, the suite is omitted by default (./dev release --skip-e2e makes that explicit); the portable application tests and packaged-image restore drill still run.

Developer’s live-suite workflow

The existing machine-specific workflow runs from the source checkout:

./dev provision-peers
./dev up
./dev e2e-full

Provisioning uses the Compose fixtures and upstream revisions in e2e/peers/. It prepares local TLS and starts the required peer fleet. Generated checkouts, credentials, certificates, and persistent state are ignored. To use an existing fleet, configure the peer paths and URLs in .env.example.

Keep the generated matrix with release evidence. It records outcomes, skips, expected failures, checkout differences, and running image IDs. A passing test establishes the exchange it asserts for that peer configuration. Skipped cases remain unverified. The source checkout’s e2e/README.md lists the release policy and report format.

Identity proofs with Mitra

./dev e2e tests/test_identity_proofs_mitra.py -v checks FEP-c390 Ed25519 proofs through both servers’ client APIs. It covers initial discovery at Mitra, verified actor Updates and removal in both directions, and preservation of Mitra’s original signed statement. Update checks read cached accounts.

Exchanges between Plamenu servers

e2e/tests/test_plamenu2_*.py uses a second instance at https://plamenu2.local. The test session creates it from the working tree with its own database, media directory, and port, then tears it down. e2e/plamenu_e2e/ephemeral.py manages this instance and the self-destruct peer.

These tests cover receiving Plamenu’s own quotes, reactions, group moderation, events, articles, collections, and follower migration. They check that the two ends agree; foreign-peer tests are still needed to check interoperability.

Check content presentation

For articles, events, media, and other structured posts, inspect both the received ActivityPub object and the remote client’s presentation. Successful delivery does not establish that the title, body, attachments, or controls are usable. Protocol details describes the wire format.

Report a federation problem

Include both software versions, the affected actor/object URLs where safe, delivery direction, redacted request/response headers and ActivityPub JSON, and whether authorized fetch is enabled. State whether the object arrived through an inbox or was fetched by URL. Remove tokens, cookies, private content, and signing keys before sharing evidence.

Contributing

Use the Codefloe repository for bug reports and pull requests. Report vulnerabilities through the security policy.

Discuss large features, schema or federation changes, and new dependencies first. A patch should explain the problem, resulting behavior, checks run, and any effects on stored data, configuration, security, or interoperability. Sign commits and include the DCO sign-off (git commit -S -s) and update Unreleased for user-visible changes.

See development for setup and checks.

Documentation

Describe the task the reader can perform and the controls or commands needed. Keep prerequisites, limitations, and recovery instructions with that task.

  • Check user-facing claims in the running interface. A route or form in the source does not prove that a user can reach or complete it.
  • Distinguish local authoring, API access, remote object handling, and test coverage.
  • Name compatibility targets separately from verified behavior.
  • Use the actual control labels. Check commands against help and configuration against the parser for the documented release.
  • Keep one detailed explanation per topic and link to it. Avoid promotional claims, unrelated comparisons, repeated caveats, and lists of internal modules in user instructions.
  • Keep investigation narratives out of task guides. Track pending public-launch work in Before going public.
  • If clear instructions require a code, packaging, or publishing change, discuss that issue before changing the implementation or documenting a workaround.

Check links and syntax for prose and example edits. ./dev docs-check also builds the application and checks runtime documentation. If CLI help changes, regenerate it with ./dev docs-generate. Shell examples in docs/examples/ must pass syntax checking and ShellCheck; deployment TOML examples must pass configuration tests.

Development

Install the pinned Rust toolchain, Docker Compose, PostgreSQL client tools, FFmpeg/ffprobe, and cargo-nextest. E2E work also needs Python 3 and uv. The development stack runs PostgreSQL 18 and Mailpit on loopback ports.

cp .env.example .env
./dev doctor
./dev up

./dev up generates an ignored configuration, creates a disposable local account, and starts Plamenu in the background. Use ./dev run for foreground operation, and ./dev logs, ./dev status, and ./dev down to manage it. Set PLAMENU_CONFIG to use your own configuration.

Keep credentials, generated configuration, database dumps, media, and .dev/ out of version control. Media defaults to .dev/media; preserve that directory when reusing a development database in another checkout.

Choose a check

Run from the checkout; development commands load .env and export DATABASE_URL. ./dev release instead uses isolated configuration and disposable services; see Releasing.

CommandPurpose
./dev testRust tests through cargo-nextest
./dev checkFormatting, Clippy, offline Cargo checks, and benchmark-budget unit tests when pytest is installed
./dev sqlxRegenerate .sqlx query metadata after SQL or migration changes
./dev sqlx-checkCheck query metadata without updating it
./dev docs-checkBuild the application, check CLI help and documentation examples, and build/check the book
./dev ciLocal check, docs-check, test, and dependency policy when cargo-deny is installed

./dev test prepares PostgreSQL’s template1 so SQLx tests can create isolated schema copies. Use the disposable development database. Select a test suite or filter when working on a specific feature:

./dev test -p plamenu --test web -E 'test(composer_)'

Commit generated .sqlx changes after running ./dev sqlx; do not hand-edit the metadata. For prose-only edits, check links and example syntax. See documentation guidance.

Performance

For changes affecting database query counts or runtime cost, run the calibrated benchmark and grade its results:

./dev bench
./dev bench-check --strict

Include the results with the change and commit the passing record in bench/results/. Explain query-count changes, including decreases caused by changed data. Known query fan-outs tracks remaining per-item database work.

The benchmark uses the versioned plamenu_bench database and machine-specific budgets. ./dev bench-concurrency measures concurrent posting and delivery fan-out. Calibration and coverage are described in bench/README.md.

Regression and federation tests

Test the affected production entry point. Transport tests should exercise the guarded HTTP client, including redirects. Mutations that enqueue federation work need rollback coverage for both the data and the outgoing jobs. Deletion tests should check the captured recipients. See architecture.

For web changes, check the rendered controls and interactions in a browser; server-response assertions cannot detect controls hidden by CSS or broken by JavaScript.

For federation changes, check serialization and the relevant peer exchange. The Python E2E suite is currently specific to one developer’s machine and is included for transparency and examples; it is not expected to work elsewhere as provided. See interoperability testing and e2e/README.md for its setup limitations and existing workflow.

Known per-item query fan-outs

This is the current list of confirmed database round-trip loops. It is not an audit journal: resolved entries and rejected findings belong in version history. A benchmark query budget is a tripwire against regression, while this file records debt that the benchmark dataset may not expose.

When fixing an entry, delete it in the same change and record the measured before/after statement count. A lower benchmark budget needs an explanation too; dataset changes can lower counts without improving code.

EntrySeverityBoundWhy it remains
migration-process-move-per-local-blockerhighunbounded local blockersEach carried relationship has distinct federation and moderation effects.
migration-process-move-per-local-followerhighunbounded local followersEach relationship produces distinct follow/unfollow work.
ingest-store-remote-attachmentsmedium100 per NoteInbound attachment rows are inserted individually.
inbox-flag-report-per-targetlow50 targetsEach local target receives a distinct report; instance reports may also enqueue webhooks.
ingest-thread-ancestor-backfilllow5 ancestorsEach missing ancestor requires guarded network fetch and full ingest.

Details

Move replay

process_move in crates/server/src/migration.rs loops over local followers and local accounts blocking the source. Both loops are unbounded and run from the inbound Move request path. Each item performs relationship checks, mutations, notifications, and/or distinct ActivityPub delivery. No benchmark currently exercises this path with a SQL query budget.

Remote attachments

store_remote_attachments in crates/server/src/ingest.rs calls media::create_remote per attachment. Input is capped at MAX_REMOTE_ATTACHMENTS = 100, so this is bounded but a hostile Note can still cause 100 serial inserts. The inline-image fallback is deduplicated separately.

Flag targets

The instance and group handle_flag paths in crates/server/src/routes/inbox.rs file one report per deduplicated local target. MAX_FLAG_OBJECTS caps the set at 50. Instance-level reports also run the configured report webhook path; group-scoped reports do not.

Thread ancestor backfill

resolve_thread_parent in crates/server/src/ingest.rs walks and then ingests up to MAX_THREAD_ANCESTORS = 5. Each step includes a guarded remote fetch and a complete remote-note ingest. This is fixed-bound rather than a traditional unbounded N+1, but its body is expensive enough to track here.

Single SQL statements containing correlated subqueries are query-plan costs, not extra round trips, and do not belong in this inventory. Investigate those with EXPLAIN and performance benchmarks instead.

Architecture

Plamenu is a Rust workspace with four internal crates:

  • plamenu-ap: ActivityStreams types, discovery, canonical URLs, signatures, integrity proofs, and text normalization.
  • plamenu-db: PostgreSQL persistence, forward migrations, durable queues, and Snowflake-style identifiers.
  • plamenu-federation: guarded outbound HTTP, content negotiation, and HTTP signature verification.
  • plamenu: axum server, APIs, workers, CLI, and server-rendered interface.

PostgreSQL stores application data. The configured media store holds file bytes; small in-process caches accelerate settings, authentication, translation, and rendering but are disposable and invalidated by mutation or short TTLs. Migrations run before the server accepts traffic.

Writes and background work

Local post, interaction, relationship, profile, group, collection, RSVP, report, relay, migration, and key-rotation actions use transactions to commit their mutations and outgoing delivery jobs together. Remote resolution precedes the transaction; optional notifications, streams, and webhooks follow commit. Connection-scoped helpers keep audience and object rendering on that transaction. Failure-injection coverage lives in atomic_outbox.rs and atomic_mutations.rs under crates/server/tests. This is not a blanket atomicity guarantee for every inbound handler or background job; those have their own retry boundaries.

Workers use PostgreSQL-backed queues. Most jobs are leased and therefore at-least-once: a crash leaves the row to be reclaimed, so handlers must be idempotent. Scheduled publication removes its claimed entry in the transaction that creates the post and delivery jobs; claim generations reject stale attempts. Poll-expiry side effects remain at-most-once. The complete classification is in QUEUES.md.

Each worker is supervised and restarted after an unexpected exit. Release builds use panic = "unwind" so the supervisor can contain task panics; the container restart policy handles process-wide failures.

Deployment boundary

Run one plamenu serve process per database. The server enforces this with a PostgreSQL advisory lock because identifier generation is process-local. The lock session is monitored during startup, serving, and shutdown. A failed heartbeat or a five-second heartbeat timeout terminates the process; it never reconnects and continues without the lock. Detection is not instantaneous. One CLI command can run alongside the server: it holds a separate monitored lock and allocates odd IDs, while the server allocates even IDs. Concurrent CLI commands are refused before initialization. Use matching CLI and server binaries; mixed-version online administration is not supported. Each lane remains process-local, so this is not database fencing or a guarantee across clock rollback followed by process restart. Horizontal multi-server serving is not supported.

All local ActivityPub identifiers are minted through crates/ap/src/urls.rs. The public domain is therefore permanent after federation begins. Remote input crosses URL/SSRF, signature, ownership, size, and HTML-sanitization boundaries before it is persisted or rendered.

See protocol and extensions for federation behavior.

Translate the interface

The web interface and mail use Fluent catalogs embedded in the binary:

  • crates/server/src/web/locales/en-US/main.ftl: English source and fallback.
  • crates/server/src/web/locales/ru/main.ftl: Russian translation.

The staff administration console is currently English-only. The interface uses the member’s language setting, then the browser’s Accept-Language, then English. Mail uses the recipient’s saved setting, falling back to English.

Edit a translation

Find the message in the English catalog and edit the matching identifier in the translation. Preserve variable names such as $count, and use Fluent selectors for the language’s plural forms. Translate each message as a whole; keep links and interpolated values where the sentence needs them.

When adding a message, add the same identifier to both catalogs. Use a name that describes its purpose and group it with the related feature. Add a comment when translators need context about a variable or where text appears.

Check syntax and matching message identifiers with:

cargo test -p plamenu --lib web::i18n

Rebuild and restart the server to see catalog edits. Select the language in account settings and check the affected page, including plural forms and any layout affected by longer text.

Add a language

Create crates/server/src/web/locales/<tag>/main.ftl using the English catalog as the message inventory. Then register it in crates/server/src/web/i18n.rs:

  1. Include the catalog and add a Language variant and Fluent bundle.
  2. Add its code to Locale::AVAILABLE and handle it in language_from_tag, bundle, tag, and direction. The picker uses names from crates/server/src/languages.rs; its inventory must contain the code.
  3. Extend the catalog tests to check its syntax, message identifiers, negotiation, and plural forms.

Regional browser tags currently select the base language: ru-RU selects Russian. A separate regional translation would also need negotiation changes.

Use messages in code

Use Locale::text or text_with inside Maud templates; Maud escapes the returned string. Locale::plain and plain_with remove Fluent’s directional isolation characters for mail and text passed to JavaScript through data-* attributes.

For a sentence containing links or styled text, pass rendered Maud Markup to Locale::markup. markup_with also accepts Fluent variables, such as a numeric plural selector. These helpers return trusted HTML: render user text through Maud before including it, and never pass it as a raw string variable.

Use the shared web::view helpers for dates. Pass numeric counts to Fluent so it can select plural forms. Resolve mail language with Locale::for_user; web routes should map typed errors or stable redirect codes to catalog messages.

Getting help

For operator questions, check the operator guide and troubleshooting notes, then ask in the project repository.

Report a problem or propose a change

  • Bug: include the Plamenu version, installation method, reproduction steps, expected and actual behavior, and relevant logs. Redact credentials, tokens, and private content.
  • Federation problem: use the interoperability issue template. Identify both server versions, which server sent the activity, which received it, and what happened on each side.
  • Feature proposal: explain the user problem and how the proposed behavior would help. Mention effects on other clients or servers where relevant.
  • Security vulnerability: follow the security policy and report privately. Do not open a public issue.

What to expect

Community help is best-effort. Experimental releases do not guarantee production availability, data recovery, upgrade or downgrade paths, exact Mastodon behavior, or private operational support.

If you use a modified image or a database outside the documented setup, include those differences in your report. You may be asked to reproduce the problem with the release binary or image and the documented PostgreSQL version.

Changing an established ActivityPub domain can break remote references to accounts and posts. Missing HTTPS or incorrect reverse-proxy settings can cause login and federation failures. Check these against the configuration guide when diagnosing a deployment.

Security policy

Supported versions

Only the latest published experimental release receives security fixes. These releases may require a fresh installation.

Reporting a vulnerability

Do not open a public issue. Email security@burning.homes with:

  • affected version and deployment shape;
  • impact and required attacker access;
  • reproduction steps or proof of concept;
  • suggested mitigation, if known;
  • your disclosure preferences and a safe way to contact you.

Please avoid accessing data you do not own, disrupting federated services, or publishing details before a fix and advisory are available. Good-faith research that follows these constraints will not be pursued by the project.

Release handling

Confirmed vulnerabilities receive a private fix, regression test, new signed release, and public advisory containing impact, affected versions, mitigation, and credit where requested. Secrets must never be sent through public CI logs or issues.

Release procedure

Prepare and test a release locally, then publish those same bytes:

./dev release
./dev release --publish

The second command reuses verified completed stages. It can also be used alone to prepare and publish in one invocation. No CI run IDs, artifact downloads, handwritten approval files, or separate promotion step are needed.

Before the first run

Every release includes Linux AMD64 and ARM64 installation archives and a container image for both architectures. Run preparation on Linux AMD64 or ARM64. It needs:

  • Python 3.11 or newer, Git, OpenSSH client tools, the pinned Rust toolchain (including rustfmt and Clippy), a native C build toolchain, pkg-config, and Perl;
  • Docker with Compose and Buildx, usable without sudo;
  • FFmpeg/ffprobe and binutils (readelf);
  • cargo-nextest, cargo-sqlx matching the workspace’s SQLx version, cargo-deny, mdbook, ShellCheck, Ruff, and the Python pytest and PyYAML packages.

The pinned binary installer can install cargo-nextest, cargo-deny, mdbook, and Cosign: python3 scripts/ci/install-tools.py. Add ~/.local/share/plamenu-ci/bin to PATH. Cosign is needed only for publication; Git SSH signing must also be configured for that step.

./dev release --plan

This lists the selected stages, missing command/package prerequisites, and missing publication or worker settings when those operations are requested. Actual runs also check Docker access, Compose, Buildx, and execution of each locally built architecture. Allow sufficient disk space for a separate checkout, Cargo output, Docker caches, and release files. Native source checks reuse a dedicated Cargo cache across release revisions. The default build uses two compilation jobs and preserves Fat LTO; it can need several GiB of RAM. --jobs N changes compilation concurrency. No CPU model, fixed database port, standing development services, or Codefloe account is required for local preparation. Internet access is needed for dependencies and container images that are not already cached.

For both architectures on one machine, Docker needs emulation for the other architecture. Docker Desktop normally includes it; on Linux Docker Engine, install the missing emulator once. For example, on AMD64:

docker run --privileged --rm \
  tonistiigi/binfmt@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 \
  --install arm64

Use --install amd64 on ARM64. The command registers QEMU with the Docker host’s kernel; it needs a host that permits that operation. Emulated compilation is slower than native compilation. The release command tests execution before starting lengthy checks and fails clearly if a local target is unavailable. See Docker’s multi-platform build documentation. An architecture sent to a native worker does not require local emulation.

Set workspace.package.version, update the workspace entries in Cargo.lock, and move the changelog’s Unreleased entry into a dated version section. Commit the release source. Preparation requires a clean checkout and captures that exact commit in a separate worktree; later work in the original checkout does not change the running release.

What preparation checks

The command runs formatting, Clippy, script and budget tests, dependency policy, static checks, documentation checks, Rust application tests, and SQLx metadata validation. Database checks use a disposable PostgreSQL 18 container bound to a random loopback port, with an empty template database for migration tests. SQLx validation compares fresh query metadata while respecting explicit !/? nullability annotations, so query-plan differences on an empty database do not produce false failures. It does not rewrite the committed .sqlx cache. The normal release path does not load .env.

For each architecture, it builds one static musl executable, packages the installation files, and assembles a runtime image containing that executable. The image’s binary hash and ELF architecture must match the installation archive. Each image then passes the installation, backup/restore, and federation drill on its build machine, using emulation when necessary. The host systemd/reboot installation path still needs a fresh-host check before initial public availability; see Before going public.

Outputs and logs live under release-artifacts/vVERSION/INPUT-ID/. The assets/ directory contains:

FileContents
plamenu-VERSION-linux-amd64.tar.gzOne Linux AMD64 executable, systemd unit, Plamenu and Caddy examples, license
plamenu-VERSION-linux-arm64.tar.gzThe equivalent installation files for Linux ARM64
plamenu-VERSION-source.tar.gzSource from the exact release commit
release.jsonSource/build settings, check outcomes, and report hashes
SHA256SUMSChecksums for the prepared downloads

There is no second executable or diagnostic archive. Tested container images remain in build-amd64/image.tar.gz and build-arm64/image.tar.gz; load one locally with docker load --input PATH. These local transport files are not release downloads. Publishing uploads the same images and combines their immutable digests under one multi-architecture registry tag. It does not recompile either executable or rebuild either runtime image.

The release page automatically lists the installation downloads, describes what is inside them, and includes a direct container-package link, pull command, immutable image reference, and verification links. Publication also links the container package to the source repository and enables its Packages tab when both use the same forge owner.

Rerunning the same command verifies saved file hashes and resumes after a failure. Static checks are refreshed after 24 hours to catch changed dependency advisories until publication starts; publication retries retain the same check results and signed files. Use --rerun to repeat all stages, or --check-only to run source checks without packaging. Changed source or compilation options select a separate output directory. Changing an architecture’s build location reruns that architecture only; other completed stages remain reusable. Do not run simultaneous publishers for the same version. An existing published version is immutable: new build bytes or check results require a new version rather than replacement uploads.

Optional developer checks

./dev release --skip-e2e

--skip-e2e explicitly omits the Python federation suite; omission is also the default. Skipping it is appropriate for someone building on another machine: the suite relies on one developer’s ad hoc peer fleet, credentials, URLs, and running services. It is not portable or expected to work elsewhere as provided; it is included for transparency and examples. Requiring that setup would test someone else’s environment rather than provide a usable build prerequisite. The normal application and packaged-image restore checks still run, including the drill’s Plamenu-to-Plamenu federation checks. Skipping the fleet does not establish interoperability with the third-party software it covers.

Maintainers with the existing fleet can opt in with --with-e2e. This uses their original checkout’s .env and peer workspace, and captures the suite report. It tests the configured live fleet; the separate restore drill tests the newly built release image. See Interoperability testing.

Timing benchmarks are the other machine-specific check. Use --with-performance only on the calibration machine in bench/release-policy.toml. The command runs and strictly grades fresh hot-path and contention reports automatically; there is no requirement to commit or supply those reports. Different CPUs cannot meaningfully pass the same timing thresholds without recalibration. Benchmark-budget unit tests remain part of the portable baseline. Both optional checks appear as not-run, with a reason, when omitted; omission is never reported as a pass.

Publishing setup

Create ~/.config/plamenu/release.toml once (or select another file with --config). For example:

repository = "https://codefloe.com/plamenu/plamenu"
registry = "codefloe.com/plamenu/plamenu"
git_remote = "codefloe"
username = "YOUR_ACCOUNT"
token_file = "~/.config/plamenu/release-token"
signing_key = "~/.config/plamenu/cosign.key"
# Optional overrides; defaults are the reviewed keys in the release source:
# public_key = "/path/to/cosign.pub"
# allowed_signers = "/path/to/allowed-signers"

Keep the token and encrypted signing key outside Git, with owner-only file permissions. The token needs repository release access and package read/write access; optional workers also need Actions access. Git uses the configured remote’s normal authentication. Supply the encrypted key password through COSIGN_PASSWORD in the publishing environment. Credentials are not passed to the compiler or the optional workers.

Publication checks repository and package-owner privacy by default; private images must use that forge owner’s registry namespace. After an explicit decision to publish publicly, set allow_public = true in this local configuration. Configure the registry namespace’s visibility deliberately; repository and package visibility are separate. The registry host receives the configured token, so use a token intended for that host.

--publish verifies the signing keys and source tag, pushes both tested images, checks each pull by immutable digest, and publishes one index containing exactly linux/amd64 and linux/arm64. It signs that index and the checksum manifest, and creates/verifies/pushes the normal SSH-signed vVERSION tag. It uploads release assets into a draft, reads every asset back, verifies them, and finalizes the draft last. It adds image.txt, cosign.pub, and SHA256SUMS.bundle to the published assets. It does not move latest or stable.

Platform images also have vVERSION-amd64 and vVERSION-arm64 tags; normal users pull vVERSION and their runtime selects the correct architecture.

Failed publication can leave an image, tag, or draft. Rerun the same command to finish matching uploads; conflicting tags, images, or assets are refused. The final URL and immutable image reference are saved in publication/ beside the prepared stages. Signing and publication remain local even when a worker builds the release.

Obtain release/cosign.pub and release/allowed-signers through reviewed signed Git history. Cosign uses explicit keys without public transparency-log submissions. Verify downloaded assets with the independently trusted key:

cosign verify --key release/cosign.pub --insecure-ignore-tlog \
  codefloe.com/plamenu/plamenu@sha256:REPLACE_WITH_RELEASE_DIGEST
cosign verify-blob --key release/cosign.pub --insecure-ignore-tlog \
  --bundle SHA256SUMS.bundle SHA256SUMS
sha256sum -c SHA256SUMS

Private images require registry login with package-read access. Keep protected backups of signing keys outside Git. Rotate keys through reviewed signed changes and retain old public keys for verifying older releases.

Optional workers

./dev release --arm64-on codefloe
./dev release --arm64-on codefloe --publish
# Optionally offload AMD64 or static checks too:
./dev release --amd64-on codefloe --arm64-on codefloe --checks-on codefloe --publish

The default builds both architectures locally. --arm64-on codefloe sends the ARM64 build and image drill to a native ARM64 worker; AMD64 stays local. These options require the configured repository and token, plus a pushed branch or tag at the selected commit containing release-worker.yml. The command dispatches the selected stage, waits, retrieves its result, checks its source and file hashes, and continues locally. There are no manual workflow inputs or artifact-retention chores. A retry reconnects to an unfinished request. Once retrieved, results are ordinary durable local files; forge artifact expiry no longer matters. If a result expired before download, retry to dispatch again or omit the worker option to run locally.

--checks-on offloads static checks only. Application/database checks, optional developer checks, signing, and publication stay local. Each image’s restore checks run where that image is built, using the same implementation. Workers are a convenience, and local preparation needs no release config. See CI/CD for workflow details and validation limits.

Documentation and build identity

Build a local preview with ./dev docs-build VERSION; the checked site is in target/docs/site/VERSION/. Every page shows its Plamenu version, release or development status, build number, and source revision. Development means that source is not tagged as that release; local changes marks a dirty preview. build.json contains the full identity and generated-file hashes.

Codefloe Pages

The documentation site is hosted on Codefloe Pages from the separate plamenu/docs repository. For a new or recreated docs repository, enable Pages once in Settings → Pages, selecting main. That repository holds only generated HTML, styles, and search files; docs-deploy maintains it automatically. Edit documentation in the application source repository. The site is public even while the source repository remains private. Codefloe serves the generated files through statichost.eu; no hosted compiler or Actions workflow is needed. See Codefloe’s Pages configuration.

Add this to the same local release configuration used for publication:

[docs]
enabled = true
repository = "https://codefloe.com/plamenu/docs"
url = "https://docs.plamenu.codefloe.page/"
branch = "main"

docs.repository selects the generated-site repository on the same forge; its SSH push URL is derived automatically, with no extra source remote needed. Docs deployment reuses token_file, allow_public, and Git signing settings. Without docs.repository, the existing repository and git_remote are used; in that case use a separate deployment branch such as pages. It needs local Git, Python 3.11+ and mdBook, but no application build, database, Docker, or Cosign key.

./dev docs-deploy                     # Publish the current committed source
./dev docs-deploy --ref v0.6.0         # Publish or retry a particular release
./dev release --publish               # Also deploy docs when enabled above

--config PATH selects a different local configuration. A standalone deployment captures the selected commit in a temporary worktree. Release publication uses its already captured source and runs docs deployment only after the signed release is available. Local release preparation does not require Pages.

The command builds and checks links locally, signs a commit containing only the generated site, and pushes it to the configured docs repository and branch. A normal Git push rejects concurrent updates. It then reads the public build marker and checks every hosted file against the local hashes, without sending forge credentials to the site. The local record and log are in target/docs/deploy/SOURCE/; the release hook keeps them under the release’s docs/ directory.

If hosting fails, the release remains published and the command reports release published; docs pending. Retry docs-deploy --ref SOURCE with the same configuration; unchanged output reuses the existing signed commit and checks hosting again. No binaries are rebuilt and no release tag or assets are changed. Hosted verification waits up to five minutes; use --timeout SECONDS when retrying a slower deployment. Never force-push over a concurrent publisher; check the current site and deliberately select the source to deploy next.

The release version comes from Cargo, the revision from the captured commit, and the release build number from that commit’s timestamp. Local and worker release builds use the same identity without depending on a forge run number. Other development builds retain their existing Git-count fallback. The admin dashboard shows the full build identity, target, and architecture.

Maintain repository access

Use this guide to inspect or restore the repository’s settings. Workflow operation is covered by CI/CD; the repository recreation and public launch are tracked in Before going public.

Inspect or apply settings

Run from the source checkout. Replace the token-file path with your local Codefloe administration credential:

python3 scripts/codefloe-settings.py --token-file /path/to/codefloe-token

The command prints the repository, branch protections, tag protections, and organization teams. Save this output before changing settings. To apply ci/codefloe-settings.json:

python3 scripts/codefloe-settings.py --token-file /path/to/codefloe-token --apply

The script currently applies only to a private repository. It adds configured maintainers and missing labels; it does not remove team members. Review team membership separately when granting or withdrawing access. Keep the administration token outside CI.

Protections

  • main accepts pull-request merges after both quick and Clippy checks pass. Direct and force pushes are disabled, including for administrators.
  • Rejected reviews and outdated branches block merging. New commits dismiss stale approvals. The independent-approval count is currently zero.
  • The maintainers team has write access and is named in CODEOWNERS. Only that team may create v* tags.

Settings can still be changed by repository administrators. Check the applied rules and access after changing team membership or recreating the repository.

Commit and release credentials

Sign authored commits and include the DCO sign-off with git commit -S -s. Verify the signature locally and on Codefloe after pushing. DCO trailers are checked by contribution CI; they do not verify the cryptographic signature.

Release signing lists the publication secrets, trusted public keys, and key recovery procedure.

CI/CD

Plamenu uses Forgejo Actions on Codefloe for contribution checks and optional, manually requested release workers. Release preparation, testing, signing, and publication can all run locally.

Contribution checks

checks.yml runs on pull request changes, pushes to main, and manual dispatch. New runs cancel older checks for the same branch or pull request.

JobChecksTimeout
quickRust formatting, shell lint, YAML/TOML/Python syntax, Python lint and formatting, dependency policy, secret scanning, public-tree rules, DCO trailers, and seed-version changes5 minutes
clippycargo clippy --locked --workspace --all-targets -- -D warnings with SQLX_OFFLINE=true10 minutes

Clippy waits for quick to pass, avoiding compilation when a static check already fails. Both use AMD64 workers and two Cargo jobs. Clippy checks test and benchmark targets without executing them; committed SQLx metadata removes the need for a database. Contribution jobs receive no release or deployment secrets, and checkout does not persist credentials.

Tool versions and Actions revisions are pinned. Rust tools and Clippy output are cached by toolchain, with the compilation cache also keyed by Cargo.lock. Only non-PR runs on main save caches. The compilation cache includes compressed dependency downloads and is saved only below a 1 GiB uncompressed limit. Installing zstd before restore and save keeps the cache format consistent.

The repository protection configuration in ci/codefloe-settings.json requires both PR statuses before merging into main, including for administrators. Direct and force pushes are disabled. The maintainers team controls v* tags. DCO trailers and cryptographic signatures are separate checks: CI checks trailers; authored commits must also be signed and their signatures verified.

The 10 September 2026 private owner-fork test passed both jobs on a fork PR and on a push to the fork’s main. A signed version tag started no workflow. A disposable target with the same main rules rejected direct/force pushes and premature merges, including while quick had passed and Clippy was still running. This establishes the owner-account path; ordinary-contributor permissions remain untested. Historical DCO failures and remaining launch work are tracked in Before going public.

Local verification and releases

./dev release owns the release pipeline. It runs locally by default: source checks, disposable database tests, compilation and packaging, and an install/restore drill against the resulting image. ./dev release --publish adds local signing and publication of those same bytes. See the release procedure for prerequisites and one-time configuration.

The Python federation fleet is developer-specific and omitted by default; --skip-e2e makes that explicit. Calibrated timing benchmarks are also optional because their thresholds depend on the designated machine. Application tests, SQLx checks, documentation checks, budget unit tests, and the container restore drill do not depend on that developer setup. Automatic contribution checks alone do not establish that a release was tested.

Optional release workers

release-worker.yml is manually dispatched by ./dev release --arm64-on codefloe or ./dev release --checks-on codefloe. Pushes, pull requests, tags, and schedules do not start release jobs. The client supplies the source/settings, reconnects to pending requests, downloads results, and validates the returned source and hashes. Operators do not copy API IDs, manifests, or evidence files.

StageWorkerResultTimeout
Build and restoreDocker, native AMD64 or ARM64Installation archive, source archive, tested runtime image, metadata90 minutes
Static checksDebian Trixie, AMD64Same static checks used locally, with logs90 minutes

Both stages execute the same Python implementation as local preparation. The optional BuildKit container has a two-CPU quota, 6 GiB RAM limit, and a 12 GiB memory-plus-swap allowance. This leaves room within the observed 7 GiB job memory limit for the worker coordinator. The latter depends on existing worker swap; it does not allocate swap. The GNU-hosted Rust toolchain targets musl with Fat LTO and one codegen unit. These worker limits are not local-machine requirements. Every release targets Linux AMD64 and ARM64; local builds can use QEMU and workers must execute natively on the requested architecture.

The worker uploads one automatically retrieved bundle with a requested three-day retention period. Once downloaded, its local copy has no forge expiry dependency. Missing, expired, or mismatched results stop that stage; repeating it locally is supported. Worker jobs have no signing or registry credentials, do not publish, and do not use contribution compilation caches. Release jobs are serialized and never automatically cancelled by newer jobs.

The earlier build/publish/promote workflows, handwritten approval format, and publishing-trial/access workflows have been removed. Build profiling remains a manual experiment, not a release prerequisite.

The private multi-architecture trial on 10 September 2026 built AMD64 locally and ARM64 on a native Codefloe worker. Both images passed installation, backup/restore, and signed federation checks. An independent full ARM64 build and the same runtime drill also passed locally under QEMU. Cold ARM64 compilation took 139 minutes under local emulation and 31 minutes on the native worker in this trial.

The published OCI index contains exactly AMD64 and ARM64. Both platforms were pulled and executed, and their static executable hashes matched their respective installation archives. Image and checksum signatures, release-asset readback, package visibility, and publication retries were verified. The live trial exposed Docker TLS context handling and package-link ordering issues, which were corrected. Publication resumed with the corrected tool while retaining the already tested images and archives. Regression tests cover those package link retries, automatic worker failure logs, stage reuse, result validation, and publication conflicts. Remaining launch checks are in Before going public.

Credentials and trust

Local preparation requires no forge credentials. Optional workers need a token for the selected repository’s Actions API. Local publication needs repository and package access, an encrypted Cosign key, and a configured Git SSH signer. The release configuration names those files; private keys remain on the publishing machine. Contribution jobs receive none of these credentials. Repository and package-owner privacy are checked before private uploads; public destinations require explicit local configuration.

Release trust is pinned in release/cosign.pub and release/allowed-signers. The signed manifest binds the release files and automatic check summary; the signed version tag identifies the source. Assets are read back and verified before the release draft is finalized. Known conflicting versions are refused.

Deployment

Deployment is a separate manual operation with environment-specific access. ./dev deploy-staging builds and deploys to the configured staging environment, checks health, and retains the previous binary for rollback. It is independent of Codefloe CI. ./dev docs-deploy builds and checks locally, pushes a signed commit of generated content to the separate plamenu/docs repository, and verifies the public site. When docs hosting is enabled in local release configuration, ./dev release --publish uses the same operation after publication, from the captured release source. A hosting failure can be retried independently. See Pages deployment.

Before going public

Finish the additional application, repository, and publishing tests first. Then squash the development history, recreate the repository, and make it public. This is the single checklist for that transition; record unresolved launch issues here rather than adding them to installation or operator guides.

Finish testing

  • Exercise local preparation and private publication end to end. The 0.6.0-multiarch-test.20260910.1 trial passed source/application/SQLx checks, built AMD64 locally and ARM64 on a native Codefloe worker, and passed both images’ installation/restore and federation drills. A full local ARM64 build and drill also passed under QEMU. The signed multi-architecture image and architecture-specific downloads were published, read back, and verified, including publication retries. Package linking was corrected in the publishing tool without rebuilding the tested artifacts. See CI/CD for the tested scope.
  • Install both published architecture archives on fresh Debian 13 VMs. Each contains one executable, systemd unit, Plamenu and Caddy examples, and license. Both passed first-Owner login, posting and media upload, real guest reboot with enabled services, and restore into an empty database and media directory. Existing login tokens, posts, media bytes, and Owner roles survived. These used PostgreSQL 17 and a private Caddy CA; restore reused the VM with replacement storage, and public DNS/ACME remain part of final public testing.
  • Test owner-account fork PRs and push contribution checks. Direct and force pushes to a disposable branch with the main protection rules were rejected, as were merges before both required statuses passed. The corrected fork PR and fork’s main push passed quick checks and Clippy. A version tag started no workflow. No test, release build, or publication jobs ran. Disposable upstream branches and rules were removed; upstream main and its protections were unchanged. The private test fork is archived with Actions disabled: the test token lacks the write:user scope required to delete a personal repository.
  • Test ordinary-contributor access with a separate account. The tested fork belongs to the repository owner, so it cannot establish outsider permissions. This check is intentionally pending until that account is available.
  • Resolve the missing DCO sign-off in history before the public transition. A fork PR containing the current development history correctly failed on commit 5cc516ca, which lacks a sign-off. The planned signed, DCO-compliant squash must be checked again. The separate OAuth placeholder false positive found by secret scanning was corrected; the full historical scan now passes.
  • Retain the test evidence outside the repository to be recreated. The private pre-public-testing-20260910 backup contains both image archives, release assets and source, check/build logs, fresh-host results and restore backups, protection-test records, and unresolved history/access notes. All eight retained release assets matched fresh authenticated downloads. These local copies do not depend on Codefloe artifact expiry.

Documentation deployment

  • Implement the shared local deployment command and release hook. Every page identifies its corresponding source build. Signed deployment commits, unchanged-content retries, concurrent pushes, and hosted failure handling are covered by tests. See the operating procedure.
  • Enable Pages and verify the live documentation. The first deployment on 10 September 2026 passed anonymous HTTPS readback of all 87 generated files, and an unchanged-source retry reused its signed deployment commit. Every page displays its version, build number, and source. Hosting uses main in the separate plamenu/docs repository. Pages is disabled on the application repository, and its temporary pages branch has been removed. Public docs publication was authorized separately; source and package visibility remain private.
  • Deploy the final public release’s docs from its exact source and verify its version/build banner and hosted files.

Prepare the public configuration

  • Set allow_public = true in the local release configuration only after authorizing the public transition. Update any retained profiling experiments’ private-only policy separately; they are not part of release preparation.
  • Update ci/codefloe-settings.json and scripts/codefloe-settings.py for the public repository. They currently require a private repository too.
  • Decide the package-owner visibility alongside repository visibility. Verify access to images separately from access to source and release assets.
  • Confirm the local release token, Cosign key, password, and Git SSH signer in the publishing setup. Retain protected key backups and confirm the public keys in release/cosign.pub and release/allowed-signers. Secrets and local credential-location notes must stay out of the public tree.
  • Remove disposable probe workflows, trial destinations, and investigation records that have no ongoing use. Check the release guide against the final tested workflow and remove stale private-only instructions.
  • Confirm the public contact addresses, repository description, license, and installation links. Keep the initial release scope to tested targets; Validate both AMD64 and ARM64 builds and installation paths.

Squash and recreate

  • Preserve a private backup of the existing repository and any issues, settings, release assets, or test evidence that should survive recreation. Git history alone does not contain all of those.
  • Confirm version 0.6.0 and date the initial public changelog entry before finalizing the source revision for release.
  • Create and sign the squashed history, retaining the DCO sign-off. Review the exact files that will be public, including generated files and fixtures.
  • Recreate the repository and restore the required setup: Actions, issue labels, the maintainers team, CODEOWNERS access, protected main, and maintainer-only v* tags. Require the quick and Clippy PR statuses.
  • Reinstall repository secrets and any repository-bound credentials or integrations. Check Git remotes, workflow URLs, and registry destinations.
  • Test the protections and automatic checks again in the recreated repository, including the diff base used with the new history. Verify commit signatures both locally and on Codefloe.

Validate the final release and public access

  • Run ./dev release --publish from the final squashed source revision. The command reruns checks for that source, builds and tests the artifacts, and creates the signed version tag and release. Earlier trial results do not substitute for checking the final source.
  • Make the repository and intended packages public, then verify anonymous cloning, release downloads, image pulls, and signature verification from a fresh environment. Run the published installation commands against those downloads.
  • Configure documentation hosting if it is part of the launch. Check that links resolve and the published docs match the released version.
  • Remove this checklist once the transition is complete; keep ongoing procedures in the maintainer and release guides.