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 the web interface: navigation, posting, and account settings.
- Install a server: requirements and deployment examples.
- Operate a server: configuration, moderation, backups, upgrades, and troubleshooting.
- Connect a client: authentication and API compatibility.
- Federation: objects, signatures, and extensions.
- Contribute: development and documentation.
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.
| Navigation | Contents |
|---|---|
| Home | Posts from accounts you follow |
| Live feeds | Local and federated public timelines |
| Search | People, posts, links, and hashtags known to the server |
| Notifications | Mentions, follows, favourites, boosts, and other activity |
| Private mentions | Conversations addressed to specific accounts |
| Bookmarks / Favourites | Saved posts / posts you have favourited |
| Lists | Timelines for selected accounts you follow |
| Groups | Communities and their threads |
| Apps | Webxdc sessions |
| People / Trending | Profile and content discovery |
| Settings | Profile, 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
| Visibility | Access |
|---|---|
| Public | Publicly readable and eligible for public feeds |
| Unlisted | Publicly readable, omitted from public feeds |
| Followers only | Accepted followers and addressed recipients |
| Private mention | Mentioned accounts |
| Local only | Signed-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.
| Path | Host | Deployment setup |
|---|---|---|
| Debian binary | A new or existing Linux server | Linux binary, systemd unit, host configuration, and Caddy |
| OCI image | Existing container infrastructure | Image 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:
| Concern | Requirement |
|---|---|
| Process | The image entrypoint runs plamenu --config /etc/plamenu/plamenu.toml serve as UID/GID 10001 |
| Configuration | Read-only file at /etc/plamenu/plamenu.toml, readable by UID 10001 |
| Media | Persistent, writable storage at /var/lib/plamenu/media, owned by UID/GID 10001 |
| Scratch | Writable /tmp; allow at least 1 GiB for large media operations |
| Database | A supported PostgreSQL database reachable from the container; do not publish PostgreSQL publicly |
| HTTP | Container port 8420 behind an HTTPS reverse proxy |
| Health | /health for liveness and /ready for database-aware readiness |
| Identity | One serving Plamenu process per database; permanent domain and account_domain |
| Proxy trust | trusted_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:
- Put the release’s immutable
image@sha256:…reference indeploy/.env. - Replace the PostgreSQL password in
.envandplamenu.tomlwith the same random value. - Set the social and Webxdc domains.
- Set
encryption_secrettoopenssl rand -hex 32and escrow it separately from PostgreSQL and media backups. - If
172.30.0.0/24overlaps another network, change it in both files. - 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:
- Open a storage that accepts container templates and choose Pull from OCI registry.
- Pull the immutable
codefloe.com/plamenu/plamenu@sha256:…reference from the release assets. - Create an unprivileged container from that imported template. Keep its image entrypoint, allocate a fixed private address, and enable start at boot.
- Add persistent storage at
/var/lib/plamenu/mediaand make it writable by UID/GID 10001. Put the secret-bearing configuration at/etc/plamenu/plamenu.toml, readable by that UID. - Give
/tmpat 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.
| Task | Guide |
|---|---|
| Set domains, database, storage, mail, and proxy settings | Configuration |
| Manage registration, accounts, reports, and server policy | Administration |
| Change the running version | Upgrading |
| Preserve and recover data | Backup and restore |
| Watch readiness, resources, and delivery | Monitoring |
| Diagnose failures | Troubleshooting |
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
domainor be a parent of it. Omitting it usesdomain. 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_PASSWORDindeploy/.env. encryption_secretandencryption_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.
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
- Record the exact Plamenu version and binary checksum or running
image@sha256:…, configuration, PostgreSQL version, and free space. - Read the release notes for migrations, configuration changes, removals, and post-upgrade work.
- Run
plamenu federation keys auditwith the current binary. - Make a coordinated stopped-write database and media backup. Verify that the matching encryption secret is independently available.
- 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 /healthis liveness: the process answers.GET /readychecks 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
/readyfailure 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
- Check
systemctl status plamenu postgresqlor the equivalent container and database status in your OCI runtime. - Inspect Plamenu and PostgreSQL logs.
- Request
/healthand/readylocally, then through public HTTPS. - Verify
database_url, database authentication, and media/scratch free space. - Check whether another Plamenu process holds the single-writer database lock.
- Confirm the reverse proxy can reach port 8420 and that
trusted_proxiesexactly 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.
| Queue | Semantics | Recovery |
|---|---|---|
| deliveries, mail, webhooks, Web Push | at-least-once | lease expiry and bounded retry |
| media processing/account media | at-least-once | lease expiry; remote work backs off |
| quote verification | at-least-once | bounded retry, then remains pending |
| reply fetch, link verification/crawl | leased single-attempt work | lease expiry bounds crash loops |
| media cleanup | at-least-once | lease expiry and bounded retry |
| account archives, bulk imports | at-least-once state lease | stale-claim sweep |
| scheduled statuses | leased retry; atomic publication | five-minute lease; queue removal commits with the post and delivery jobs |
| poll-expiry side effects | at-most-once | durable 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
| Work | Limit |
|---|---|
| Items examined per page | 20 |
| Pages per member action | 1 |
| Collection documents fetched initially | 2 |
| Concurrent jobs per origin | 1 |
| Queued jobs per origin | 2 |
| JSON response | 2 MiB |
| Job duration | 30 seconds |
| Individual object fetches with bare-IRI resolution | 5 per page |
These limits are fixed. Operators can change retention from 1 to 3650 days; its default is 90 days.
Read the history state
| State | Meaning or next step |
|---|---|
idle | No active fetch |
queued / fetching | Work is pending or running |
partial | More may be available; the member can choose Load older posts |
complete | The observed collection had no unfetched next page |
unsupported | The collection, attribution, authorization, or federation policy prevented this attempt |
backoff | A 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_historyreads local history state.POST /api/v1/accounts/:id/remote_history/fetchrequests more history.- An authenticated first-page
GET /api/v1/accounts/:id/statusescan 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.
| Error | What to check |
|---|---|
invalid_client | Send the client ID and secret from this server in the POST body. |
invalid_scope | Requested scopes must be recognized and covered by the application’s registration. |
invalid_grant | Check code age, exact redirect URI, application, and PKCE verifier. Start a new authorization attempt; a failed exchange may have consumed the code. |
unsupported_grant_type | Use 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
| Area | Operations |
|---|---|
| Accounts | Login, registration, password and profile changes, account deletion |
| Discovery | Site information, people, search, and object resolution |
| Communities | List, create, edit, follow, block, and moderate |
| Posts and comments | List, create, edit, delete, vote, and save |
| Inbox | Replies, mentions, private messages, unread counts, and read state |
| Reports | Post/comment reports, moderator listing, and resolution |
| Media | Pictrs upload, retrieval, and deletion; account and admin media lists |
| Custom emoji | Create, edit, and delete |
| Administration | Site 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
GroupandWebxdcSessionintype; - expose
inbox,outbox, andfollowersendpoints; - set
attributedToto its creator or controlling actor; - set
webxdcProtocoltohttps://w3id.org/fep/752d; - identify exactly one immutable bundle as described below;
- advertise
sendUpdateIntervalandsendUpdateMaxSize; 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.
| Term | Meaning |
|---|---|
WebxdcSession | Additional type of the session Group actor |
WebxdcUpdate | Durable Webxdc update object |
WebxdcEphemeral | Non-persistent binary packet object |
webxdcProtocol | Protocol IRI on session and control activities |
webxdcUpdate | Complete Webxdc update as a JSON literal |
webxdcData | Standard Base64 packet data |
webxdcSerial | Positive coordinator-assigned durable serial |
webxdcMaxSerial | Non-negative acceptance replay boundary |
sendUpdateInterval | Minimum durable update interval, milliseconds |
sendUpdateMaxSize | Maximum 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
- [ActivityPub] ActivityPub, W3C, 2018.
- [Webxdc] Webxdc specification.
- [Webxdc-sendUpdate] sendUpdate.
- [Webxdc-setUpdateListener] setUpdateListener.
- [Webxdc-Realtime] joinRealtimeChannel.
- [Data-Integrity] Resource integrity, W3C.
- [RFC-2119] Key words for use in RFCs.
- [RFC-8174] Ambiguity of uppercase vs lowercase in RFC 2119.
- [RFC-4648] Base encodings.
- [FEP-888d] Using w3id.org/fep for namespaces.
Copyright
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.
| Command | Purpose |
|---|---|
./dev test | Rust tests through cargo-nextest |
./dev check | Formatting, Clippy, offline Cargo checks, and benchmark-budget unit tests when pytest is installed |
./dev sqlx | Regenerate .sqlx query metadata after SQL or migration changes |
./dev sqlx-check | Check query metadata without updating it |
./dev docs-check | Build the application, check CLI help and documentation examples, and build/check the book |
./dev ci | Local 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.
| Entry | Severity | Bound | Why it remains |
|---|---|---|---|
migration-process-move-per-local-blocker | high | unbounded local blockers | Each carried relationship has distinct federation and moderation effects. |
migration-process-move-per-local-follower | high | unbounded local followers | Each relationship produces distinct follow/unfollow work. |
ingest-store-remote-attachments | medium | 100 per Note | Inbound attachment rows are inserted individually. |
inbox-flag-report-per-target | low | 50 targets | Each local target receives a distinct report; instance reports may also enqueue webhooks. |
ingest-thread-ancestor-backfill | low | 5 ancestors | Each 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:
- Include the catalog and add a
Languagevariant and Fluent bundle. - Add its code to
Locale::AVAILABLEand handle it inlanguage_from_tag,bundle,tag, anddirection. The picker uses names fromcrates/server/src/languages.rs; its inventory must contain the code. - 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
pytestandPyYAMLpackages.
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:
| File | Contents |
|---|---|
plamenu-VERSION-linux-amd64.tar.gz | One Linux AMD64 executable, systemd unit, Plamenu and Caddy examples, license |
plamenu-VERSION-linux-arm64.tar.gz | The equivalent installation files for Linux ARM64 |
plamenu-VERSION-source.tar.gz | Source from the exact release commit |
release.json | Source/build settings, check outcomes, and report hashes |
SHA256SUMS | Checksums 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
mainaccepts 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
maintainersteam has write access and is named inCODEOWNERS. Only that team may createv*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.
| Job | Checks | Timeout |
|---|---|---|
quick | Rust formatting, shell lint, YAML/TOML/Python syntax, Python lint and formatting, dependency policy, secret scanning, public-tree rules, DCO trailers, and seed-version changes | 5 minutes |
clippy | cargo clippy --locked --workspace --all-targets -- -D warnings with SQLX_OFFLINE=true | 10 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.
| Stage | Worker | Result | Timeout |
|---|---|---|---|
| Build and restore | Docker, native AMD64 or ARM64 | Installation archive, source archive, tested runtime image, metadata | 90 minutes |
| Static checks | Debian Trixie, AMD64 | Same static checks used locally, with logs | 90 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
mainprotection rules were rejected, as were merges before both required statuses passed. The corrected fork PR and fork’smainpush 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; upstreammainand its protections were unchanged. The private test fork is archived with Actions disabled: the test token lacks thewrite:userscope 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-20260910backup 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
mainin the separateplamenu/docsrepository. Pages is disabled on the application repository, and its temporarypagesbranch 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 = truein 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.jsonandscripts/codefloe-settings.pyfor 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.pubandrelease/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.0and 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
maintainersteam,CODEOWNERSaccess, protectedmain, and maintainer-onlyv*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 --publishfrom 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.