The social side of every Moshpit name. One process serves social.<name>.pit.world
for every name: social.family.ancestry.pit.world, social.seo.rank.pit.world, and
whatever gets registered next. A community starts the first time someone visits. There is
no setup step. Open source (MIT).
Each community has two kinds of posts:
- Members' posts. People sign in with pit.world, the one account for every
*.pit.worldsite (Sign in with CoinPay underneath), and write posts and replies. They don't make a new account or set a password. - The AT Protocol network, filtered to the niche. One process reads every public relay and jetstream at the same time, merges them, keeps one copy of each post, and stores the posts that carry a hashtag one of the communities follows. The network posts are labelled with the relays that carried them and link to the post on bsky.app.
We read the public firehose anonymously and read-only. We don't create accounts on Bluesky or any other service, and we don't authenticate to any relay.
| Web | https://social.<name>.pit.world: feed (All / Network / Members, tag chips), /t/<tag>, /p/<id>, /u/<id>, /about, /status, /admin, /settings. Server-rendered, no JavaScript, light and dark |
| API | /api/v1/* JSON, below |
| AT Protocol | a feed generator per name: did:web:social.<name>.pit.world, feed at://did:web:social.<name>.pit.world/app.bsky.feed.generator/niche |
| RSS | /feed.xml (?tag= works) |
| CLI | bun add -g pitsocial, then pitsocial feed family.ancestry |
| MCP | claude mcp add pitsocial -- pitsocial mcp |
| TUI | not in v0.1 (see the roadmap) |
pitsocial feed family.ancestry # members + network, newest first
pitsocial feed seo.rank --tag seo --tab network
pitsocial tags family.ancestry
pitsocial status family.ancestry # every relay: state, events/s, lag, matched
pitsocial post family.ancestry "Found the 1880 census page! #genealogy" --token ps_...
pitsocial serve # run the server
pitsocial mcp # MCP server on stdio--json prints JSON. PIT_SOCIAL_TOKEN saves passing --token; make a token at
https://social.<name>.pit.world/settings. PIT_SOCIAL_URL=http://127.0.0.1:8794 sends
every name to one server. The name travels as X-Pit-Name, and the server believes that
header only from loopback.
read_feed, list_tags, relay_status, post. post needs PIT_SOCIAL_TOKEN and posts
as the token's owner.
GET /api/v1/feed?tab=all|network|members&tag=&limit=&before=&cursor= |
items, newest first, with a next link |
GET /api/v1/tags |
the tags this community follows |
GET /api/v1/status |
relay health and counts |
GET /api/v1/me |
who the session or token is |
POST /api/v1/posts |
{text, reply_to?} with Authorization: Bearer ps_... (30 an hour) |
GET /api/v1/posts/:id, DELETE /api/v1/posts/:id |
one member post with its replies; delete your own |
GET /.well-known/did.json |
did:web document with a #bsky_fg service |
GET /xrpc/app.bsky.feed.describeFeedGenerator |
the one feed |
GET /xrpc/app.bsky.feed.getFeedSkeleton?feed=&limit=&cursor= |
network post URIs, newest first |
pit.world is the front door. It forwards social.<name>.pit.world to 127.0.0.1:8794,
keeps Host, and adds X-Pit-Name, X-Real-IP and X-Forwarded-Proto. Those headers are
believed only when the TCP peer is loopback. From any other peer the name comes from
Host: a social. prefix, a .pit.world suffix, and two or more DNS labels in between.
Anything else gets a 404. pit.world forwards only names that exist, so the community row
is created on first request.
A new community starts with tags derived from its name:
- the labels joined (
#familyancestry) - each label, unless it is too generic to follow alone (
#familyis everyone's dinner) - synonyms from a small built-in map (
ancestry→#genealogy #familyhistory #familytree;seo→#searchengineoptimization) - a few niche words from the name's pit.world listing (
GET https://pit.world/api/listing?domain=<name>)
The tags refresh weekly until an admin edits them on /admin. A community can follow at
most 30 tags.
src/ingest.ts opens every relay in PIT_SOCIAL_RELAYS at once:
- jetstream (
/subscribe?wantedCollections=app.bsky.feed.post): JSON, already filtered to posts, about 0.05 MB/s. - full relays (
com.atproto.sync.subscribeRepos): one CBOR header and one CBOR body per frame, with records inside a CAR. Decoding uses@atcute/cborand@atcute/car. Commits with no post create or delete are skipped before the CAR is opened, so a full relay costs about 10 ms of CPU per second under Bun. Its bandwidth is about 2.4 MB/s.
Only app.bsky.feed.post creates are considered. Hashtags come from
app.bsky.richtext.facet#tag facets, from #word in the text, and from the record's
tags. A post is kept only if some community follows one of its tags; an in-memory map
from tag to communities is rebuilt whenever tags change. A post seen on several relays is
stored once (deduplicated by at-uri), and the record keeps every relay that carried it.
When the author deletes a post on the network, we delete it here too. We store the text,
tags, langs, createdAt, an embed summary (link title and URL, image count, quote) and
the relays. We never download images. Handles are resolved lazily from plc.directory for
display and cached for a day; a failed lookup only means the DID is shown instead.
Each relay:
- reconnects on its own with jittered backoff (1 s up to 60 s)
- keeps a cursor (seq, or jetstream
time_us) and replays it only when it is less than 10 minutes old - reconnects after 90 s of silence
When a relay's events arrive more than PIT_SOCIAL_MAX_LAG_S (120 s) late for 30 s, its
backlog is shed: the cursor is dropped and the relay reconnects live, and the shed is
logged. If it is still that late after a live reconnect, the relay itself is behind
upstream, so we leave it alone for 10 minutes and say so on /status. One bad relay never
stops the others. Network posts are pruned hourly: older than PIT_SOCIAL_RETENTION_DAYS
(30), and beyond PIT_SOCIAL_CAP (20,000) per community.
Relay health (state, events/s, lag, last event, matched, reconnects, sheds, last error) is
saved to the database every 5 s, so /status and /api/v1/status work even from a web
process running with PIT_SOCIAL_INGEST=off.
This is a @profullstack/bridges client. For
host H = social.<name>.pit.world:
authorizeUrl = https://pit.world/bridge/authorize
tokenUrl = https://pit.world/bridge/token
clientId = H
redirectUri = https://H/auth/bridge/callback
clientSecret = base64url(HMAC-SHA256(key = PIT_BRIDGE_KEY, message = clientId)) # no padding
The routes are GET /auth/bridge (begin), GET /auth/bridge/callback and POST /signout.
A visitor who isn't signed in is tried once per browser session with prompt=none. If
pit.world answers login_required, a session cookie stops it from looping. Bots, API calls
and non-navigation requests are never bounced, and PIT_SOCIAL_BRIDGE_AUTO=off turns
silent sign-in off.
A person is one users row per bridge sub (their CoinPay DID) across all
communities. Membership in a community starts with their first post there. Only sub
links accounts, never email. A session works only on the host it was made for.
Admins: the subs in PIT_SOCIAL_ADMINS are admins everywhere and can make anyone an admin
of one community on /admin. Signing in first doesn't make you an admin. Admins edit tags
and hide reported posts. Members can report posts and delete their own.
- Pages use a strict CSP:
default-src 'none'; style-src 'self'; img-src 'self'; form-action 'self'. - Writes are protected by a double-submit CSRF cookie plus
Sec-Fetch-Site. JSON writes with a session need a same-originOrigin. Bearer tokens are exempt, because a browser never sends them on its own. - Everything from the network is HTML-escaped, and links from it are
nofollow. - API tokens are stored only as SHA-256 hashes.
- Every SQL string is a plain literal (no template literals, no concatenation), so the threatcrush scan in CI stays at zero findings.
bun install
PIT_SOCIAL_INGEST=off bun src/server.ts # http://127.0.0.1:8794, data in ~/.local/share/pit-social
curl -H 'x-pit-name: family.ancestry' http://127.0.0.1:8794/api/v1/tags
bun test && bunx tsc --noEmit
bun scripts/smoke-relays.ts 20 # join every relay for 20 s, print what each deliveredEvery variable, with defaults, is in deploy/app.env.example.
The service runs as root from /opt/pit-social and listens on 127.0.0.1:8794 only.
pit.world terminates TLS for social.<name>.pit.world and forwards to it, so there is no
nginx site for this app.
git clone https://github.com/moshcoder/pit-social /opt/pit-social
install -d -m 700 /root/.local/share/pit-social
cp /opt/pit-social/deploy/app.env.example /root/.local/share/pit-social/app.env
chmod 600 /root/.local/share/pit-social/app.env
# set PIT_BRIDGE_KEY (the same key pit.world derives client secrets from) and PIT_SOCIAL_ADMINS
cd /opt/pit-social && ./bin/install.sh # OpenInstall: bun install, systemd unit pit-social, health check on /healthbin/install.sh (settings in bin/install.conf) writes and manages its own
pit-social.service. It reads /root/.local/share/pit-social/app.env, and the database
goes to /root/.local/share/pit-social/pit-social.db. If you would rather install the
hand-written unit deploy/pit-social.service, copy it to
/etc/systemd/system/ and skip install.sh activate. install.sh refuses to overwrite a
unit it did not write.
To have CI deploy every merge, as family.ancestry does, install
deploy/pit-social-deploy as /usr/local/bin/pit-social-deploy.
Then add a root key restricted to it (restrict,command="/usr/local/bin/pit-social-deploy")
and set the repo secrets PS_DEPLOY_KEY and PS_KNOWN_HOSTS. Until those secrets are set,
.github/workflows/deploy.yml does nothing.
Bandwidth: each full relay is about 2.4 MB/s (about 200 GB/day) inbound, and a jetstream
is about 0.05 MB/s. The default list has five full relays. To cut traffic, set
PIT_SOCIAL_RELAYS to the jetstreams plus one or two full relays.
- Publish member posts to the network. Local posts are already stored as
app.bsky.feed.postrecords (text, tag facets with UTF-8 byte offsets,createdAt), so a later version can write them from a PDS. v0.1 does not publish anything to the network. - Make the feed discoverable in Bluesky. The feed generator endpoints and the
did:webdocument are live. For the Bluesky app to list the feed, anapp.bsky.feed.generatorrecord has to be published in some repo, which needs a PDS account. We deliberately don't create one in v0.1. - A TUI (
pitsocial tui). - Images, as links to the author's PDS blob, never stored here.
MIT