# SocialSecretary — Installation Guide
## Version 2 — September 2026

*Drop a file in a folder. Talk to the Fediverse.*

SocialSecretary is a headless ActivityPub sidecar. It handles the Fediverse
social handshake — followers, signatures, delivery — so that any publishing
tool can participate in the Fediverse without understanding ActivityPub. Your
publishing tool writes a JSON file into a directory. SocialSecretary does the
rest.

This guide takes you from a fresh Ubuntu server to a live Fediverse presence
at `@you@yourdomain`, able to receive followers and broadcast posts from any
tool that can write a file.

It is a manual installation, on purpose. There is no install script. Every
step is short, every step says why it exists, and when it is done you will
know what is on your server and why. That knowledge is what lets you operate
it afterwards, and it is what an install script would have taken from you.

**Estimated time:** one to two hours for a first installation, most of it
reading. **Assumed skills:** you can connect to a server with SSH, edit a
text file there, and copy a command from this page. Nothing else is assumed.

---

## Before You Begin

### What SocialSecretary is, and is not

SocialSecretary is a small Node.js process that runs alongside your publishing
setup. It does one thing: it handles the social layer of ActivityPub. It does
not manage content, host a website, or provide a user interface. It is the
administrative clerk, not the editor-in-chief.

The hard part of ActivityPub — HTTP signatures — is about 150 lines of careful
code using only Node's built-in modules. The complexity of Mastodon and its
relatives comes from everything else they are trying to be at the same time:
platform, recommendation engine, moderation system, growth mechanism. Strip
that away and what is left is signed JSON and a list of addresses. That is
what SocialSecretary is.

**What ships as code** (you do not edit these):

| File | Role |
|---|---|
| `index.mjs` | HTTP server and router |
| `inbound.mjs` | Receives follows, likes, replies, and everything else the Fediverse sends you |
| `outbound.mjs` | Broadcasts your posts to your followers |
| `signatures.mjs` | Signs what you send; verifies what you receive |
| `followers.mjs` | Your follower list |
| `ledger.mjs` | The archive of every object you have sent — posts, updates, deletes, likes, boosts, undos |
| `watermark.mjs` | Remembers what has already been broadcast |
| `log.mjs` | Writes the activity log — everything sent and received, one JSON line per event |
| `config.mjs` | Reads your configuration and derives every path from it |

**What you provide**, from the annotated stubs in the `stubs/` directory:

| File | Role |
|---|---|
| `socialsecretary_config.mjs` | Your settings — the one file you fill in |
| `actor.json` | Your ActivityPub identity |
| `webfinger.json` | How the Fediverse finds you from your handle |
| `Caddyfile` | Routes traffic to the right place |
| `socialsecretary.service` | Keeps SocialSecretary running |
| `denylist.txt` | Optional: domains you refuse to hear from |

Every stub has its instructions written inside it as comments. Read them.
They explain not just what to fill in but why each field exists and what goes
wrong if you get it wrong. Someone who reads the stubs understands the whole
system before running a single command.

### What you will have at the end

This is the layout the rest of this guide builds, and the layout the operator
guide assumes. Come back to it whenever a step's purpose is unclear.

```
/opt/socialsecretary/                 SocialSecretary itself (owned by secretary)
  index.mjs, inbound.mjs, ...         the code
  socialsecretary_config.mjs          your settings
  stubs/                              the templates you copied from
  keys/YOUR_DOMAIN.COM/YOUR_HANDLE/
    private.pem                       signs everything you send — never leaves this server
    public.pem                        published in actor.json so others can verify you
  actors/YOUR_DOMAIN.COM/YOUR_HANDLE/ created automatically on first run
    outbox/                           your publishing tool writes envelopes here
    processing/, failed/              in-flight and failed envelopes (see operator guide)
    mailboxes/                        what the Fediverse sends you, sorted by kind
    archive/                          every object you have ever published
    index/                            lookup tables derived from archive/ and mailboxes/
    followers.json                    who follows you
    last_broadcast.txt                the watermark: what has been broadcast
    retry.json                        deliveries waiting to be retried
    log/                               what Secretary sent and received — the third
                                        contract; see the Activity Log Specification
    denylist.txt                      optional blocklist

/opt/secretary/                       home directory of the secretary user (empty)

/opt/sites/YOUR_DOMAIN.COM/public/    static files served by Caddy
  actor.json                          your identity document
  .well-known/webfinger               discovery document
  avatar.png                          your avatar

/etc/caddy/Caddyfile                  Caddy's routing configuration
/etc/systemd/system/socialsecretary.service
                                      the service definition
```

### Conventions in this guide

Replace `YOUR_DOMAIN.COM` with your domain and `YOUR_HANDLE` with the handle
you want, everywhere they appear. Your Fediverse identity will be
`@YOUR_HANDLE@YOUR_DOMAIN.COM`. Choose the handle now; changing it later means
starting over, because it is baked into your identity document and every
follower's cached copy of it.

Commands are shown with `sudo` where root is needed and without it where it
is not. That is deliberate — see Step 2 for why SocialSecretary itself never
runs as root.

---

## Step 1: What You Need

### A server

A VPS running Ubuntu 22.04 or later. 1 GB of RAM is sufficient;
SocialSecretary idles at around 50 MB. It has been run in production on the
smallest droplet DigitalOcean sells.

### A domain, with DNS already pointing at the server

An `A` record (and `AAAA` if you have IPv6) for `YOUR_DOMAIN.COM` pointing at
the server's address. It must be resolving before Step 7, because Caddy
obtains your TLS certificate by proving to Let's Encrypt that it answers for
that name. Check from your own machine:

```bash
dig +short YOUR_DOMAIN.COM
```

It should print the server's IP address. If it prints nothing, wait for DNS
to propagate before continuing.

Your handle lives at the domain's root: `@you@yourdomain.com`, not
`@you@social.yourdomain.com`. A subdomain works technically but reads as
second-class in a way a root domain does not.

### Software on the server

Three packages. Each is explained at the step that uses it.

```bash
# Node.js 20 — runs SocialSecretary
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs

# Caddy — reverse proxy and automatic HTTPS
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy

# openssl — generates your keypair (usually already installed)
sudo apt install -y openssl
```

Verify:

```bash
node --version    # v20 or later
caddy version
openssl version
```

There is no `npm install` anywhere in this guide. SocialSecretary has no
dependencies beyond Node itself, and the process manager is systemd, which
Ubuntu already has. Nothing gets installed from npm.

---

## Step 2: Create the Service User

SocialSecretary runs as its own user, `secretary`. It is never run as root
and never as your login user.

```bash
sudo useradd --system --home-dir /opt/secretary --create-home \
  --shell /usr/sbin/nologin secretary
```

What each option is for:

- `--system` — a system account: no password, no aging, a low UID, and it does
  not appear on login screens. It exists to run one program.
- `--home-dir /opt/secretary --create-home` — the user needs a home directory
  even though nothing of SocialSecretary's lives there. Node, openssl, and
  various tools expect `$HOME` to exist and behave badly when it does not.
  Keeping it under `/opt` rather than `/home` signals that it is not a person.
- `--shell /usr/sbin/nologin` — nobody can log in as this user, not even with
  a password, because there is none. If an attacker ever obtains its
  credentials they get a program that refuses to open a shell.

Why a dedicated user at all: SocialSecretary holds your private key, accepts
requests from the entire Fediverse on `/inbox`, and writes files to disk. If
it is ever compromised, the damage is limited to what `secretary` can touch —
which, after the ownership steps below, is `/opt/socialsecretary` and nothing
else. Your login user, your other services, and the rest of the filesystem
are out of reach. This is the single cheapest security measure in the whole
installation and it costs one command.

---

## Step 3: Download and Install SocialSecretary

```bash
cd /tmp
curl -O https://socialsecretary.pub/dist/socialsecretary-latest.tar.gz
curl -O https://socialsecretary.pub/dist/socialsecretary-latest.tar.gz.sha256
curl -O https://socialsecretary.pub/dist/socialsecretary-latest.tar.gz.asc
```

Three files: the archive, its checksum, and its signature. Now verify it.
This takes a minute and it is the only step in this guide that protects you
from installing something other than what was published.

```bash
gpg --auto-key-locate clear,wkd --locate-keys rob@socialsecretary.pub
gpg --verify socialsecretary-latest.tar.gz.asc socialsecretary-latest.tar.gz
sha256sum -c socialsecretary-latest.tar.gz.sha256
```

What each line does, and what it proves:

**`gpg --locate-keys`** fetches the release signing key from
`socialsecretary.pub` itself, using Web Key Directory — the same idea as the
WebFinger lookup that will let people find *you* in Step 6: ask the domain,
and the domain answers. No keyserver, no copying a key out of a web page.
It prints a fingerprint. It must be:

```
735C CBB0 C5C2 73DD E2C5 0FA1 A84B 1564 762B 3085
```

If it is anything else, stop. Either the domain has been compromised or you
are not talking to it.

**`gpg --verify`** checks that the archive was signed by that key. Want:
`Good signature from "Rob <rob@socialsecretary.pub>"`. You will also see a
warning that the key is not certified with a trusted signature — that is
gpg noting that you have not personally vouched for the key, which is true
and fine; the fingerprint check above is your vouching. A `BAD signature`
means the archive is not what was published. Delete it.

**`sha256sum -c`** confirms the download arrived intact. Want:
`socialsecretary-latest.tar.gz: OK`. The signature already implies this;
the checksum is the quick check you can run without gpg at all.

Do not proceed past a failed check. Delete the files and download again;
if it fails twice, something is wrong upstream and you should say so.

```bash
sudo mkdir -p /opt/socialsecretary
sudo tar -xzf socialsecretary-latest.tar.gz -C /opt/socialsecretary --strip-components=1
sudo chown -R secretary:secretary /opt/socialsecretary
ls /opt/socialsecretary
```

You should see the `.mjs` files from the table above, a `stubs/` directory,
a `docs/` directory, and a `tools/` directory. Everything under
`/opt/socialsecretary` is now owned by `secretary`, which is the user that
will read and write it.

---

## Step 4: Directories

SocialSecretary creates its own state directories on first run. Two things it
will not create, and you must:

```bash
# The static files Caddy serves: identity, discovery, avatar
sudo mkdir -p /opt/sites/YOUR_DOMAIN.COM/public/.well-known

# The directory that will hold your keypair
sudo mkdir -p /opt/socialsecretary/keys/YOUR_DOMAIN.COM/YOUR_HANDLE
sudo chown -R secretary:secretary /opt/socialsecretary/keys
```

The keys directory is left for you to create on purpose. Its absence on
startup is a setup error SocialSecretary reports clearly — "private key not
found at …" — rather than something it papers over by generating a key you
did not know about. A key you did not knowingly create is a key you cannot
knowingly protect.

`/opt/sites/YOUR_DOMAIN.COM/public` is owned by root and read by Caddy. That
is correct: nothing in it changes at runtime, and SocialSecretary never writes
to it. If you later run a website from the same server, this is where its
files go too.

---

## Step 5: Generate Your Keypair

```bash
cd /opt/socialsecretary/keys/YOUR_DOMAIN.COM/YOUR_HANDLE
sudo -u secretary openssl genrsa -out private.pem 2048
sudo -u secretary openssl rsa -in private.pem -pubout -out public.pem
sudo chmod 600 private.pem
sudo chmod 644 public.pem
ls -l
```

You should see `private.pem` readable only by `secretary` and `public.pem`
readable by everyone. Both owned by `secretary`, because they were created as
`secretary`.

What the keypair is for: every request SocialSecretary sends to another
server — a post to a follower, a like, a follow acceptance — is signed with
`private.pem`. Every server that receives it fetches your `actor.json`, reads
`public.pem` from it, and checks the signature. That is how the Fediverse
knows a post claiming to be from you is from you. There is no account, no
password, and no central authority; there is a key, and you hold it.

`private.pem` never leaves this directory. SocialSecretary reads it at
broadcast time. Your publishing tool never touches it and never needs to.
Back it up somewhere safe: if it is lost, your identity is lost, and every
follower would have to follow you again under a new key.

RSA 2048 is the size the Fediverse expects. Larger keys work but buy nothing
here; some older implementations reject them.

---

## Step 6: Create Your Identity Files

Two small JSON files tell the Fediverse who you are. Both are static — served
by Caddy, never touched by SocialSecretary.

```bash
sudo cp /opt/socialsecretary/stubs/actor.json \
   /opt/sites/YOUR_DOMAIN.COM/public/actor.json
sudo cp /opt/socialsecretary/stubs/webfinger.json \
   /opt/sites/YOUR_DOMAIN.COM/public/.well-known/webfinger
```

Note the second file is renamed: `webfinger`, no extension. That is the path
the WebFinger standard requires.

Now open each and fill it in. **The stubs end with a block of comments
explaining every field. Read them, fill in the fields, then delete the
comment block** — JSON does not permit comments, and a file that still has
them will not parse.

**`actor.json`** — the fields that must be right:

- `id`: `https://YOUR_DOMAIN.COM/actor.json`, exactly, with no trailing slash.
- `preferredUsername`: `YOUR_HANDLE`. Must match `actor` in Step 8's config.
- `published`: today's date, `2026-09-05T00:00:00Z`. Set once, never change it.
- `publicKeyPem`: your public key as a single JSON string, with the line breaks
  written as `\n`. This command prints it in the right form:

```bash
awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' \
  /opt/socialsecretary/keys/YOUR_DOMAIN.COM/YOUR_HANDLE/public.pem
```

Paste the entire output — beginning with `-----BEGIN PUBLIC KEY-----\n` — as
the value of `publicKeyPem`, inside quotes.

- `icon`: leave the block out for now; Step 12 adds it. Do not leave a
  placeholder URL — a broken avatar URL looks worse than none.

**`webfinger`** has two placeholders, your handle and your domain. The
`subject` line must read `acct:YOUR_HANDLE@YOUR_DOMAIN.COM`; this string is
compared byte-for-byte against what someone types into a Mastodon search box.

Verify both files are valid JSON before going on:

```bash
node -e "JSON.parse(require('fs').readFileSync('/opt/sites/YOUR_DOMAIN.COM/public/actor.json','utf8')); console.log('actor.json OK')"
node -e "JSON.parse(require('fs').readFileSync('/opt/sites/YOUR_DOMAIN.COM/public/.well-known/webfinger','utf8')); console.log('webfinger OK')"
```

A parse error here almost always means the comment block was not deleted, or
a `\n` in the key was typed as a real line break.

---

## Step 7: Configure Caddy

Caddy is the reverse proxy: it answers HTTPS on your domain and routes each
request to the right place — SocialSecretary for the ActivityPub endpoints,
static files for identity, and later your website for everything else. It
also obtains and renews your TLS certificate automatically; you will never
run a certificate command.

```bash
sudo cp /opt/socialsecretary/stubs/Caddyfile /etc/caddy/Caddyfile
sudo nano /etc/caddy/Caddyfile
```

Replace `YOUR_DOMAIN.COM` everywhere it appears. The stub's comments explain
each block; the summary:

| Path | Goes to | Why |
|---|---|---|
| `/inbox` | SocialSecretary | The one endpoint the Fediverse *sends* to. The only writable surface on your domain. |
| `/trigger` | SocialSecretary | Your publishing tool pings this after writing an envelope |
| `/outbox*` | SocialSecretary | Your post history; Mastodon reads it for your profile |
| `/followers*` | SocialSecretary | Your follower list, for follower counts |
| `/objects/*` | SocialSecretary | Individual published objects |
| `/activities/*` | SocialSecretary | Individual activities. Mastodon fetches these to verify a post is authentic; without this route your posts may be silently dropped. |
| `/actor.json` | static file | Your identity |
| `/.well-known/webfinger` | static file | Discovery |
| `/avatar.png` | static file | Added in Step 12 |
| everything else | placeholder | Replace with your website when you have one |

Order matters. Caddy tries `handle` blocks top to bottom and the last one
matches everything, so every specific route must appear above it. If you
later add a static file and it comes back as `text/plain` or as the
placeholder text, this is why — it needs its own `handle` block above the
catch-all.

```bash
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

`validate` catches syntax errors before they take your site down. `reload`
applies the file without dropping connections. Now confirm the identity
files are reachable from the outside — run this from your own machine, not
the server:

```bash
curl -sI https://YOUR_DOMAIN.COM/actor.json | grep -i content-type
curl -s "https://YOUR_DOMAIN.COM/.well-known/webfinger?resource=acct:YOUR_HANDLE@YOUR_DOMAIN.COM"
```

The first should say `application/activity+json`. The second should print
your webfinger file. If either fails, Caddy's log is the place to look:
`sudo journalctl -u caddy -n 50`.

---

## Step 8: Configure SocialSecretary

```bash
sudo cp /opt/socialsecretary/stubs/socialsecretary_config.mjs \
   /opt/socialsecretary/socialsecretary_config.mjs
sudo chown secretary:secretary /opt/socialsecretary/socialsecretary_config.mjs
sudo nano /opt/socialsecretary/socialsecretary_config.mjs
```

This is the one file that holds your settings. It is kept separate from the
code so that updating SocialSecretary never touches it. Every field is
explained in the file. The ones you must set:

- `domain` — `YOUR_DOMAIN.COM`
- `actor` — `YOUR_HANDLE`, matching `preferredUsername` in `actor.json`
- `baseDir` — `/opt/socialsecretary`, unless you installed elsewhere

Everything else has a default that is right for a first installation. Leave
`deliveryAllowlist` alone for now; Step 11 uses it.

---

## Step 9: First Run, By Hand

Before handing SocialSecretary to systemd, run it once in the foreground so
you can see it start:

```bash
cd /opt/socialsecretary
sudo -u secretary node index.mjs
```

You should see:

```
[index] SocialSecretary starting
[index] actor: @YOUR_HANDLE@YOUR_DOMAIN.COM
[index] port:  3000
[index] outbox: /opt/socialsecretary/actors/YOUR_DOMAIN.COM/YOUR_HANDLE/outbox
[index] watching outbox: ...
[index] HTTP server listening on port 3000
```

On this first run SocialSecretary creates `actors/YOUR_DOMAIN.COM/YOUR_HANDLE/`
and everything inside it. Press `Ctrl-C` to stop it. Then:

```bash
ls -la /opt/socialsecretary/actors/YOUR_DOMAIN.COM/YOUR_HANDLE/
```

The directories from the layout in "Before You Begin" are now there, owned
by `secretary`.

If instead you see `private key not found`, the key is not at the path
`config.mjs` derives from your config: `baseDir/keys/domain/actor/private.pem`.
Compare that path, character for character, with where Step 5 put the file.

Port 3000 is not reachable from the internet — Caddy talks to it on
localhost. Nothing in this guide opens it in a firewall, and nothing should.

**Optional: `denylist.txt`.** One of the stubs from "Before You Begin" hasn't
been used yet. If you already know domains you want to refuse from the
start, copy it in now that the actor directory exists to hold it:

```bash
sudo cp /opt/socialsecretary/stubs/denylist.txt \
   /opt/socialsecretary/actors/YOUR_DOMAIN.COM/YOUR_HANDLE/denylist.txt
sudo chown secretary:secretary \
   /opt/socialsecretary/actors/YOUR_DOMAIN.COM/YOUR_HANDLE/denylist.txt
```

One domain per line. It's read on every request — no restart needed, ever,
even later — and an empty or absent file blocks nothing. Most installs skip
this at first and add entries later, from experience; see
`docs/OPERATOR.md` §4.8 for how blocking actually behaves once it's in
place.

---

## Step 10: Run It as a Service

SocialSecretary must start on boot, restart if it crashes, and run as
`secretary`. On Ubuntu that job belongs to systemd, which is already running
every other service on the machine.

```bash
sudo cp /opt/socialsecretary/stubs/socialsecretary.service \
   /etc/systemd/system/socialsecretary.service
sudo systemctl daemon-reload
sudo systemctl enable --now socialsecretary
sudo systemctl status socialsecretary
```

`enable` makes it start on boot; `--now` starts it immediately. `status`
should show `active (running)`, and the last lines should be the same startup
log you saw in Step 9.

The service file is short enough to read in full, and you should:

```ini
[Unit]
Description=SocialSecretary — headless ActivityPub sidecar
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=secretary
Group=secretary
WorkingDirectory=/opt/socialsecretary
ExecStart=/usr/bin/node /opt/socialsecretary/index.mjs
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/socialsecretary

[Install]
WantedBy=multi-user.target
```

The lines that matter: `User=secretary` runs it as the service user;
`Restart=on-failure` restarts it if it exits with an error, five seconds
later; `ProtectSystem=strict` with `ReadWritePaths=/opt/socialsecretary`
makes the entire filesystem read-only to the process except its own
directory, which is the promise of Step 2 enforced by the kernel rather than
by file permissions alone.

**Why systemd and not pm2.** Earlier versions of this guide used pm2, the
Node process manager, and a first production install ran on it. It was
replaced for three reasons, in order of how much they cost.

First, pm2 keeps its state per user, under `$HOME/.pm2`. Running SocialSecretary
as a dedicated user means every `pm2` command must name that user and that
directory, and any command that forgets talks to a *different* pm2 — one that
reports nothing running when SocialSecretary is running, or starts a second
copy beside the first. Both of those happened. The second copy fought the
first for port 3000 and the pair restarted each other twenty-three times.
systemd has one daemon and one process list; `systemctl start` on a running
service is a no-op.

Second, pm2 is installed from npm, globally, as root. It would have been the
only npm package in a project whose whole design is that there are none.

Third, systemd is already here, already managing Caddy, already writing to
the journal you will use for every other service. One process manager for
the whole machine is simpler than two.

Reading the log, from now on:

```bash
sudo journalctl -u socialsecretary -f          # follow live
sudo journalctl -u socialsecretary -n 100      # last 100 lines
sudo journalctl -u socialsecretary --since today
```

Restarting, after a code update or a config change:

```bash
sudo systemctl restart socialsecretary
```

---

## Step 11: Verify With ActivityPub.Academy

[ActivityPub.Academy](https://activitypub.academy) is a Mastodon instance
built for testing ActivityPub implementations — no real people, no social
dynamics, and it shows you the raw activities it sends and receives. Test
here before any real instance sees you.

First, restrict delivery to the Academy so that nothing you do while testing
can reach anyone else. In `socialsecretary_config.mjs`:

```javascript
deliveryAllowlist: ['activitypub.academy'],
```

then `sudo systemctl restart socialsecretary`. This is a delivery filter,
not a follower filter: followers elsewhere stay in `followers.json` and start
receiving posts the moment you set the list back to `[]`.

**Test 1 — Discovery.** Create an account on the Academy and search for
`@YOUR_HANDLE@YOUR_DOMAIN.COM`. Your profile should appear, with your display
name and (after Step 12) your avatar. If it does not, Step 7's two `curl`
checks are the first thing to re-run.

**Test 2 — Follow.** Follow yourself from the Academy account. In the
journal you should see the Follow arrive, be verified, and be accepted:

```bash
sudo journalctl -u socialsecretary -n 20
```

and the Academy should show the follow as accepted within a few seconds.
`followers.json` now has one entry.

**Test 3 — Broadcast.** Write a test envelope into the outbox. This is the
same thing your publishing tool will do; you are doing it by hand once.

```bash
sudo -u secretary tee /opt/socialsecretary/actors/YOUR_DOMAIN.COM/YOUR_HANDLE/outbox/test-001.json > /dev/null << 'EOF'
{
  "version": "1",
  "type": "create",
  "id": "test-001",
  "url": "https://YOUR_DOMAIN.COM/test-001",
  "published": "2026-09-05T12:00:00Z",
  "content": "<p>Hello from SocialSecretary.</p>"
}
EOF
curl -X POST http://localhost:3000/trigger
```

Set `published` to now, in UTC. The journal should show the envelope picked
up, accepted into the archive, and delivered — `1/1 delivered`. The post
appears in your Academy timeline.

The `url` in this envelope points at a page that does not exist. For a test
that is fine: the Academy displays the post from the content you sent. For
real posts, `url` must be a real page — see "Connecting a Publishing Engine"
below.

When all three pass, set `deliveryAllowlist: []` and restart. You are live.

---

## Step 12: Add Your Avatar

Accounts without avatars read as suspicious or automated to human followers.
This is a Fediverse social fact, not a technical requirement, and it is worth
five minutes.

Prepare a square image, at least 400×400 pixels, under 1 MB, PNG or JPEG.

```bash
sudo cp your-avatar.png /opt/sites/YOUR_DOMAIN.COM/public/avatar.png
curl -sI https://YOUR_DOMAIN.COM/avatar.png | grep -i content-type
```

It must say `image/png` (or `image/jpeg`). If it says `text/plain`, the
Caddyfile catch-all intercepted the request; the stub already has a
`handle /avatar.png` block, so check that it is still above the catch-all.

Add to `actor.json`, before the `publicKey` block:

```json
"icon": {
  "type": "Image",
  "mediaType": "image/png",
  "url": "https://YOUR_DOMAIN.COM/avatar.png"
},
```

Re-run the JSON check from Step 6. No restart is needed — `actor.json` is a
static file. New followers see the avatar immediately; existing ones see it
when their instance refreshes its cached copy of your actor document, which
takes hours to a few days.

---

## You Are Federated

`@YOUR_HANDLE@YOUR_DOMAIN.COM` exists in the Fediverse. No platform, no
instance account, no database. One Node process, two JSON files, a Caddy
config, and a service unit — every one of which you placed yourself and can
find again.

From here:

- **Connecting a Publishing Engine**, below, is how posts get in.
- The **Operator Guide** (`docs/OPERATOR.md`) is how you look after it —
  what to check weekly, what the directories accumulate, what a warning in
  the journal means, and — §4.7 — how to deploy a new release onto an
  install that's already running. That procedure isn't repeated here: Step 3
  above is how you get the *first* copy of SocialSecretary onto a fresh
  server; §4.7 is how you replace it later without disturbing your config,
  keys, or data, and it's the one place that procedure is written down.
- The **Handoff Envelope Specification** (`docs/handoff-envelope-spec.md`)
  is the full contract for envelopes: updates, deletes, visibility, replies,
  mentions, attachments, likes and boosts.
- The **Mailbox Specification** (`docs/mailbox-spec.md`) is the contract for
  reading what the Fediverse sends you.
- The **Activity Log Specification** (`docs/activity-log-spec.md`) is the
  contract for the third direction: what Secretary itself did with the
  other two — every delivery, every resolved or unresolved mention, every
  follow and accept — as a plain, append-only file your tool can read
  without an API.
- **zdat** is a command-line publishing client built against those
  contracts; it also serves a small website from your posts. It has its own
  installation and user guides. Any tool that can write a file can do what
  zdat does.

---

## Connecting a Publishing Engine

Drop a JSON envelope in the outbox and SocialSecretary broadcasts it:

```
/opt/socialsecretary/actors/YOUR_DOMAIN.COM/YOUR_HANDLE/outbox/
```

The smallest useful envelope:

```json
{
  "version": "1",
  "type": "create",
  "id": "my-first-post-20260905120000",
  "url": "https://YOUR_DOMAIN.COM/posts/my-first-post",
  "published": "2026-09-05T12:00:00Z",
  "title": "My First Post",
  "summary": "A short teaser shown before the fold.",
  "content": "<p>The post, as HTML.</p>"
}
```

Two fields need care. `id` must be unique and must be reproducible — an
update or delete later must use the same `id`, so derive it from something
stable rather than a random value. `url` must be a real page on your site
that serves the post, because other servers fetch it to display your post
and to confirm it is yours.

SocialSecretary watches the outbox and picks new envelopes up on its own
within a few seconds. To broadcast immediately, ping the trigger endpoint
after writing the file:

```bash
curl -X POST http://localhost:3000/trigger
```

Both paths run the same pipeline. The trigger exists because file watching is
unreliable on some filesystems; the watcher exists so a tool that cannot make
an HTTP request still works.

That is the whole interface. Your publishing tool never imports
SocialSecretary's code, never reads its internals, never touches the archive.
A file in a directory and an optional ping.

Updates, deletes, followers-only and direct posts, replies, mentions,
attachments, likes, boosts, and retracting a like or boost are all
envelopes too. The Handoff Envelope Specification defines every field; the
example above covers a small fraction of it.

The other direction — replies, likes, boosts, mentions arriving from the
Fediverse — lands in `mailboxes/`, sorted by kind, one JSON file per
activity. The Mailbox Specification defines what your tool will find there.

---

## Troubleshooting

**`systemctl status` shows `failed` or `activating (auto-restart)`.**
`sudo journalctl -u socialsecretary -n 50` shows the error. The three most
common on a fresh install: private key not at the derived path (Step 5),
a syntax error in `socialsecretary_config.mjs` (Step 8), or a permissions
error because something under `/opt/socialsecretary` is not owned by
`secretary` (Step 3, `chown -R`).

This can also happen on an install that has been running fine for months:
`socialsecretary_config.mjs` still contains `YOUR_DOMAIN.COM` because the
stub template got copied over the live file — an installer template and a
live, edited config look alike enough to swap by mistake, and it has
happened more than once in production. The error names the placeholder
value directly, so it is not a guessing game; the fix is restoring your
real config, not editing this one back into shape. See
`docs/OPERATOR.md` §4.1 and §4.7, and the project ledger entries §4.7
points to, for why this specific mix-up gets its own paragraph instead of
being folded into "syntax error" above — it is not a syntax error, it is
valid JavaScript with the wrong values, and `node --check` will find
nothing wrong with it.

**Follows are not received.** Confirm Caddy routes `/inbox` to port 3000:
`sudo caddy validate` and look at the `handle /inbox` block. Confirm the
domain resolves and `actor.json` is served with the right content type
(Step 7's checks). Look for `signature verification failed` in the journal,
which means the sender could not be verified — usually a clock more than a
few minutes wrong on your server (`timedatectl`).

**Posts are not broadcast.** Check `last_broadcast.txt` in the actor
directory: an envelope whose `published` is older than that timestamp is
skipped as already sent. Check `failed/` for the envelope and its
`.error.txt`. Try the trigger by hand and watch the journal.

**Discovery fails — handle not found.** Run the webfinger `curl` from
Step 7 exactly as written. The `subject` in the file must match the search
query byte for byte, including `acct:`.

**Avatar not showing.** Step 12's content-type check. If correct and it is
still missing, the remote instance has a cached actor document; wait, or
search your handle from that instance to prompt a refresh.

**Posts show on the Academy but not on a real instance.** Real instances
fetch `url` and expect either an HTML page or, when they ask for
`application/activity+json`, an ActivityPub object. If your site does not
yet answer at that URL, that is the cause. zdat's site does this; any other
publishing engine must be taught to.
