Every path on the server that belongs to SocialSecretary or to the project site, with who owns it, what mode it has, and — the column that explains the other two — what writes to it at runtime. zdat's paths are not here; they are zdat's manifest's.
The rule behind every row: a file is owned by whatever writes
it, and by root if nothing does. Static files are root's
because nothing running on the server should be able to change them.
Everything under /opt/socialsecretary is
secretary's because Secretary writes there, and the systemd
unit (ProtectSystem=strict,
ReadWritePaths=/opt/socialsecretary) makes the rest of the
filesystem read-only to it regardless of ownership.
Modes: directories 755 unless noted; files
644 unless noted. The actor data directories carry the
setgid bit (2775, shown by ls as
drwxrwsr-x) from their creation in April; it is harmless —
new files inherit the secretary group, which is the group
that should own them — and is left as found.
/opt/socialsecretary/Owner secretary:secretary throughout.
| Path | Mode | Written by | Notes |
|---|---|---|---|
/opt/socialsecretary/ |
755 | release deploy (as root, then chown -R) |
|
index.mjs inbound.mjs
outbound.mjs ledger.mjs
followers.mjs signatures.mjs
watermark.mjs config.mjs |
644 | release deploy | Never at runtime |
socialsecretary_config.mjs |
644 | you, by hand | Never in a release archive. Back it up. See L-021 for what happens when a stub lands here |
stubs/ and contents |
755 / 644 | release deploy | Templates; never read at runtime |
tools/ and contents |
755 / 644 | release deploy | make-release.sh and audit-permissions.sh
755 |
docs/ and contents |
755 / 644 | release deploy | Same files as public/docs/ on the site |
LICENSE README.md
RELEASE-NOTES.md RELEASE-NOTES.html
VERSION socialsecretary-signing-key.asc |
644 | release deploy | RELEASE-NOTES.html rendered by pandoc, same as
docs/*.html (1.0.1) |
keys/ |
755 | you, once (INSTALL Step 4) | Secretary reads; never writes |
keys/socialsecretary.pub/rob/private.pem |
600 | you, once (Step 5) | The identity. Readable by secretary only. Back up
offline |
keys/socialsecretary.pub/rob/public.pem |
644 | you, once | Also embedded in actor.json |
/opt/socialsecretary/actors/socialsecretary.pub/rob/Owner secretary:secretary throughout. Created by
Secretary on first run.
| Path | Mode | Written by | Growth / clearing |
|---|---|---|---|
outbox/ |
2775 | the publishing client (zdat) writes envelopes; Secretary removes them | Empty when idle |
processing/ |
2775 | Secretary | Empty when idle; stale files warned about, never auto-cleared (L-007) |
failed/ |
2775 | Secretary | Empty when healthy; you clear after reading the
.error.txt |
mailboxes/{replies,mentions,private,likes,boosts,deletions,other}/ |
2775 | Secretary writes; the client moves to processed/ |
Bounded only by the client running |
mailboxes/processed/*/ |
2775 | the client | Clear at will (OPERATOR §5.5) |
archive/ |
2775 | Secretary (ledger.mjs) |
Permanent record; never clear |
archive_summary.json |
664 | Secretary | Derived |
index/by-url/ |
2775 | Secretary (ledger.mjs) |
Derived from archive/ |
index/by-envelope-id/ |
2775 | Secretary (ledger.mjs) |
Derived from archive/. New 1.0.1 — self-healing: a
lookup miss walks archive/ and writes the entry it finds,
so this directory is never load-bearing on its own |
index/received/ |
2775 | Secretary (inbound.mjs); seeded once by
triage_deletions.mjs |
Derived from mailboxes/ |
followers.json |
664 | Secretary | The only record of who follows you. Back up |
last_broadcast.txt |
664 | Secretary (watermark.mjs) |
Derived |
retry.json |
664 | Secretary | [] when healthy |
log/ |
2750 (not the 2775 the rest of actors/
uses) |
Secretary (log.mjs), append only |
The third contract (docs/activity-log-spec.md).
Do not rotate — do not point logrotate at it (OPERATOR
§5.4): Secretary manages its own growth, starting a new
activity_log-<timestamp>.jsonl inside this directory
whenever the current one would pass logRollSizeMB.
Superseded the single top-level activity_log.jsonl in
1.0.1; on first start after upgrading, that old file is moved in here
unmodified, named for its own first line's timestamp, and never appended
to again |
log/activity_log-<timestamp>.jsonl |
640 (not the 664 the rest of actors/
uses) |
Secretary (log.mjs) |
Deliberately narrower than every other file here —
log.mjs's own header comment: group-readable so a future
client added to the secretary group can read the log, but
never group-writable, so nothing but Secretary itself can extend or
alter an append-only record. A file migrated in from 1.0.0 keeps
whatever mode it already had (the migration moves it "without rewriting
a byte," metadata included) — L-033 found one still at 664 for exactly
this reason; tools/audit-permissions.sh checks for it
now |
denylist.txt |
664 | you, by hand | Read on every request |
inbox/ |
2775 | nothing | Legacy, empty, created by index.mjs; removal is a 1.0.1
code change |
/opt/secretary/| Path | Owner | Mode | Notes |
|---|---|---|---|
/opt/secretary/ |
secretary:secretary |
755 | Home for $HOME's sake; nothing of Secretary's lives
here |
/opt/secretary/.pm2/ |
secretary:secretary |
— | pm2 state for zdat-site only. Goes when zdat migrates
to systemd |
/etc/systemd/system/| Path | Owner | Mode | Written by |
|---|---|---|---|
socialsecretary.service |
root:root |
644 | you, from stubs/socialsecretary.service |
/opt/sites/socialsecretary.pub/public/Owner root:root throughout. Read by Caddy
(caddy user, via world-read). Nothing running
writes here. All updates are by hand as root, or by the release
procedure.
| Path | Mode | Purpose | Updated when |
|---|---|---|---|
public/ |
755 | site root | — |
index.html |
644 | home page | copy edits |
RELEASE-NOTES.html |
644 | rendered by pandoc | every release, from RELEASE-NOTES.md |
actor.json |
644 | ActivityPub identity | profile change, key rotation (never) |
avatar.png |
644 | avatar | — |
socialsecretary-signing-key.asc |
644 | signing key, armored | key renewal (2028-09) |
.well-known/ |
755 | ||
.well-known/webfinger |
644 | discovery | handle change (never) |
.well-known/openpgpkey/ |
755 | WKD | |
.well-known/openpgpkey/policy |
644 | required by WKD; empty | never |
.well-known/openpgpkey/hu/ |
755 | ||
.well-known/openpgpkey/hu/xarhuw9jcphm6ir9akb945o6mpabjubu |
644 | signing key, binary | key renewal |
docs/ |
755 | documentation | every release |
docs/*.html (7) |
644 | rendered by pandoc | every release, from docs/*.md |
docs/*.md (7) |
644 | sources, offered for download | every release |
docs/style.css |
644 | shared stylesheet | design change |
dist/ |
755 | releases | |
dist/socialsecretary-<v>.tar.gz
.sha256 .asc |
644 | each release, kept | every release; never replaced once published |
dist/socialsecretary-latest.tar.gz .sha256
.asc |
644 | copies of the newest | every release |
/etc/caddy/| Path | Owner | Mode | Notes |
|---|---|---|---|
Caddyfile |
root:root |
644 | Three owners' routes in one file; the header comment says which is
which. Reference copy: Caddyfile.live in the project
outputs |
All of the following, run together in one pass, is
tools/audit-permissions.sh — it exits non-zero if anything
below would have printed. The blocks here stay as a fallback for a box
that doesn't have the script yet, and as the reference the script is
checked against, not the other way around: if the two ever disagree,
this table and these blocks are what's right, and the script gets fixed
to match — see docs/LEDGER.md L-033 for why that order
matters (the reverse, script-as-ground-truth, is how a real drift went
unnoticed for months).
Run on the server. Anything printed is a deviation.
# Nothing under /opt/socialsecretary owned by anyone but secretary
sudo find /opt/socialsecretary ! -user secretary -o ! -group secretary
# The private key is 600 and nothing else under keys/ is more permissive than 644
sudo find /opt/socialsecretary/keys -type f ! -name private.pem ! -perm 644
sudo find /opt/socialsecretary/keys -name private.pem ! -perm 600
# No data file under the actor directory is executable
sudo find /opt/socialsecretary/actors -type f -perm /111
# log/ is narrower than the rest of actors/: 2750 not 2775, 640 not 664
# (log.mjs's own design; L-033 found a file that had drifted to 664)
sudo find /opt/socialsecretary/actors -type d -name log ! -perm 2750
sudo find /opt/socialsecretary/actors -path '*/log/*' -type f ! -perm 640
# Nothing under the site root owned by anyone but root, or writable by anyone but root
sudo find /opt/sites/socialsecretary.pub/public ! -user root -o ! -group root
sudo find /opt/sites/socialsecretary.pub/public -perm /022
# The unit and the Caddyfile are root's and not world-writable
ls -l /etc/systemd/system/socialsecretary.service /etc/caddy/Caddyfile
To restore the whole state in one pass, in the order it matters — the
broad actors/ sweep first, then the log/
exception narrowed back down afterward, because a later
find wins over an earlier one on the same files:
sudo chown -R secretary:secretary /opt/socialsecretary
sudo find /opt/socialsecretary -type d -exec chmod 755 {} \;
sudo find /opt/socialsecretary -type f -exec chmod 644 {} \;
sudo chmod 755 /opt/socialsecretary/tools/make-release.sh
sudo chmod 755 /opt/socialsecretary/tools/audit-permissions.sh
sudo chmod 600 /opt/socialsecretary/keys/socialsecretary.pub/rob/private.pem
sudo find /opt/socialsecretary/actors -type d -exec chmod 2775 {} \;
sudo find /opt/socialsecretary/actors -type f -exec chmod 664 {} \;
sudo find /opt/socialsecretary/actors -type d -name log -exec chmod 2750 {} \;
sudo find /opt/socialsecretary/actors -path '*/log/*' -type f -exec chmod 640 {} \;
sudo chown -R root:root /opt/sites/socialsecretary.pub/public
sudo find /opt/sites/socialsecretary.pub/public -type d -exec chmod 755 {} \;
sudo find /opt/sites/socialsecretary.pub/public -type f -exec chmod 644 {} \;
The second block is idempotent and safe to run at any time; it is
what sudo chown -R in OPERATOR §2.1 is shorthand for.
Before L-033, this block had the same drift its own actors/
sweep would have caused — resetting log/ to 2775/664 —
since written before that exception existed; running it today does the
same broad sweep, then correctly narrows log/ back down in
the two lines added for it, rather than leaving log/
wherever the sweep left it.