Pasture: snac2 2.95

snac2 joins the pasture, built from its tag (images/snac2); scenarios/snac.sh drives its Mastodon API and reads its
own files, since its timelines lag behind what it has taken in: follows, posts, replies, likes, boosts, a poll, edits,
deletions, direct messages, the unfollow and statistics, 27 checks, with no change to PrivaPub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 00:19:56 +02:00
1 parent 1c0dfeb9a4
commit 5721bfd796
7 files changed
+181

No files matched your search

+4
View File
@@ -561,6 +561,10 @@ tools/pasture/run.sh down # removes e
checks. checks.
- **Pins (`scenarios/pins.sh`, needs mastodon):** a Mastodon account pins and unpins while alice follows it, alice pins - **Pins (`scenarios/pins.sh`, needs mastodon):** a Mastodon account pins and unpins while alice follows it, alice pins
and unpins while it follows her, and a fresh account's earlier pin shows once PrivaPub resolves it. 8 checks. and unpins while it follows her, and a fresh account's earlier pin shows once PrivaPub resolves it. 8 checks.
- **snac2 (2.95):** built from its tag (`images/snac2`, the project publishes no image), its data in the
`pasture-snac` volume from `snac init` and `snac adduser`; the password comes from `snac resetpwd` and the token from
its login form and a code grant. Its API lags behind what it has taken in, so `scenarios/snac.sh` reads its files
(`snac_stored`, `snac_in_timeline`). 27 checks.
- **Mitra (5.9.1):** its image on the shared Postgres (database `mitra`), as `mitra.test`, with the pasture's bundle as - **Mitra (5.9.1):** its image on the shared Postgres (database `mitra`), as `mitra.test`, with the pasture's bundle as
the system's roots; mitrauser comes from its CLI and its token from a password grant. Its search resolves an account the system's roots; mitrauser comes from its CLI and its token from a password grant. Its search resolves an account
elsewhere only without `type`. `scenarios/mitra.sh`, 28 checks. elsewhere only without `type`. `scenarios/mitra.sh`, 28 checks.
+1
View File
@@ -34,6 +34,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d
- **NodeBB 4.16.1** - **NodeBB 4.16.1**
- **Smithereen 1.0.3** - **Smithereen 1.0.3**
- **Mitra 5.9.1** - **Mitra 5.9.1**
- **snac2 2.95**
- **Activity-Relay 2.0.9** and **aode-relay 0.3.129**, as relays PrivaPub reads from - **Activity-Relay 2.0.9** and **aode-relay 0.3.129**, as relays PrivaPub reads from
- in the town only (a seeded community checked server by server): **Hollo 0.9.19**, **Iceshrimp.NET 2026.1.2-beta**, - in the town only (a seeded community checked server by server): **Hollo 0.9.19**, **Iceshrimp.NET 2026.1.2-beta**,
**Pleroma 2.10.2** **Pleroma 2.10.2**
+13
View File
@@ -753,6 +753,19 @@ PeerTube's own instance account announces each new video too, which we drop: nob
follow only PeerTube-like channels and accounts, never a persona. It checks the `Host` header against its own name, follow only PeerTube-like channels and accounts, never a persona. It checks the `Host` header against its own name,
without a port, before it gives out its OAuth client. without a port, before it gives out its OAuth client.
### snac2 2.95
- Keeps everything in files and works through what it receives one message at a time, sometimes minutes behind; its
Mastodon API's timelines lag behind what it has taken in, and give an object a new id when it changes. The scenario
reads snac's own files instead (`object/<md5>.json`, the user's `private.idx`).
- Its API takes a poll only as form fields (`poll[options][]`), has no conversations, and gets its token from its own
login form (`/oauth/x-snac-login`) and a code grant. A `Delete` takes the post out of the timeline and keeps the
object.
- Its single-threaded server answered one delivery with a 502 through Caddy; PrivaPub's retry 22 seconds later went in.
- **Pasture evidence (2026-10-05, `tools/pasture/scenarios/snac.sh`):** 27 checks pass, with no change to PrivaPub:
follows both ways, posts, replies, likes and boosts both ways, a poll and alice's vote, edits and deletions both ways,
direct messages both ways, the unfollow, statistics.
### Mitra 5.9.1 ### Mitra 5.9.1
- Signs its deliveries with RSA (draft-cavage) and adds an FEP-8b32 proof made with its Ed25519 key (FEP-521a), which - Signs its deliveries with RSA (draft-cavage) and adds an FEP-8b32 proof made with its Ed25519 key (FEP-521a), which
+5
View File
@@ -108,6 +108,11 @@ nodebb.test {
reverse_proxy pasture-nodebb:4567 reverse_proxy pasture-nodebb:4567
} }
snac.test {
tls internal
reverse_proxy pasture-snac:8001
}
mitra.test { mitra.test {
tls internal tls internal
reverse_proxy pasture-mitra:8383 reverse_proxy pasture-mitra:8383
+10
View File
@@ -0,0 +1,10 @@
# snac2 2.95 from its tag: the project publishes no image
FROM docker.io/library/alpine:3.22 AS builder
RUN apk -U --no-progress --no-cache add git curl-dev build-base
RUN git clone --depth 1 --branch 2.95 https://codeberg.org/grunfink/snac2 /build && cd /build && make \
&& make PREFIX=/out/usr/local PREFIX_MAN=/out/usr/local/share/man install
FROM docker.io/library/alpine:3.22
RUN apk -U --no-progress --no-cache add libcurl tzdata
COPY --from=builder /out /
EXPOSE 8001
+40
View File
@@ -0,0 +1,40 @@
# snac2 2.95 (grunfink): a small ActivityPub server in C that keeps everything in files, with a Mastodon API. Built from
# its tag (images/snac2), as snac.test; its libcurl reads the system's roots, over which the pasture's bundle goes. Its
# data directory is made by `snac init` and snacuser by `snac adduser`; the API token comes from its own login form
# (/oauth/x-snac-login) and a code grant.
SNAC_IMAGE=${SNAC_IMAGE:-localhost/pasture-snac2:2.95}
snac_up() {
podman image exists "$SNAC_IMAGE" || podman build -q -t "$SNAC_IMAGE" "$here/images/snac2" >/dev/null
local st="$here/.state/snac"
mkdir -p "$st"
podman volume exists pasture-snac || podman volume create --label pasture=1 pasture-snac >/dev/null
if ! podman run --rm -v pasture-snac:/data $SNAC_IMAGE test -e /data/data/server.json; then
printf '0.0.0.0\n8001\nsnac.test\n\n\n' | podman run --rm -i -v pasture-snac:/data $SNAC_IMAGE snac init /data/data >/dev/null
podman run --rm -v pasture-snac:/data $SNAC_IMAGE snac adduser /data/data snacuser </dev/null >/dev/null 2>&1
fi
# a password the pasture knows ("New password for user snacuser is ...")
[ -s "$st/password" ] || podman run --rm -v pasture-snac:/data $SNAC_IMAGE snac resetpwd /data/data snacuser 2>&1 </dev/null \
| sed -n 's/.* is \([^ ]*\)$/\1/p' | head -1 > "$st/password"
podman run -d --replace --name pasture-snac --label pasture=1 --network $net -v pasture-snac:/data \
-v "$ca/bundle.pem:/etc/ssl/certs/ca-certificates.crt:z,ro" $SNAC_IMAGE snac httpd /data/data >/dev/null
for _ in $(seq 1 30); do
site snac.test -s -o /dev/null -w '%{http_code}' https://snac.test:6443/.well-known/nodeinfo 2>/dev/null | grep -q 200 && break
sleep 1
done
snac_token > "$st/snacuser.token"
echo "snac: https://snac.test:6443"
}
# snac_token: an API token for snacuser, through an app, its login form and a code grant
snac_token() {
local app id secret code
app=$(site snac.test -s -X POST https://snac.test:6443/api/v1/apps -d 'client_name=pasture&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read+write+follow')
id=$(echo "$app" | python3 -c 'import json, sys; print(json.load(sys.stdin)["client_id"])')
secret=$(echo "$app" | python3 -c 'import json, sys; print(json.load(sys.stdin)["client_secret"])')
code=$(site snac.test -s -X POST https://snac.test:6443/oauth/x-snac-login --data-urlencode login=snacuser \
--data-urlencode "passwd=$(cat "$here/.state/snac/password")" --data-urlencode redir=urn:ietf:wg:oauth:2.0:oob --data-urlencode "cid=$id")
site snac.test -s -X POST https://snac.test:6443/oauth/token --data-urlencode grant_type=authorization_code --data-urlencode "code=$code" \
--data-urlencode "client_id=$id" --data-urlencode "client_secret=$secret" --data-urlencode redirect_uri=urn:ietf:wg:oauth:2.0:oob \
| python3 -c 'import json, sys; print(json.load(sys.stdin).get("access_token", ""))'
}
+108
View File
@@ -0,0 +1,108 @@
# snac2 2.95: follows both ways, posts, replies, likes, boosts, a poll, an edit and a deletion both ways, direct
# messages, the unfollow, statistics. snac keeps everything in files and federates in plain ActivityPub. Driven through
# its Mastodon API as snacuser, with the token peers/snac.sh got.
MI=https://snac.test:6443
MIT=$(cat "$here/.state/snac/snacuser.token" 2>/dev/null)
mi() { site snac.test -s -H "Authorization: Bearer $MIT" "$@"; }
mi_json() { local method=$1 path=$2 body=$3; mi -X "$method" "$MI$path" -H 'Content-Type: application/json' -d "$body"; }
mi_status_of() { mi "$MI/api/v1/statuses/${1:-none}"; }
# snac_stored <uri> <expression>: the expression over snac's own copy of an object, read from its files (`o`, None when
# it holds none). Its API's timelines lag behind what it has taken in, and give an object a new id when it changes
snac_stored() {
local md5
md5=$(printf '%s' "$1" | md5sum | cut -c1-32)
podman exec pasture-snac cat "/data/data/object/${md5:0:2}/$md5.json" 2>/dev/null | j "o = d; print($2)"
}
# snac_in_timeline <uri>: whether the object is in snacuser's timeline (its private.idx)
snac_in_timeline() { podman exec pasture-snac grep -q "$(printf '%s' "$1" | md5sum | cut -c1-32)" /data/data/user/snacuser/private.idx; }
p_home_has() { curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(next(((s.get('reblog') or s)['id'] for s in d if '$1' in ((s.get('reblog') or s)['content'] or '')), ''))"; }
p_status() { curl -s -H "$PH" "$P/api/v1/statuses/$1" | j "print(d.get('$2'))"; }
echo "snac"
[ "$(mi "$MI/api/v1/accounts/verify_credentials" | j "print(d['username'])")" = "snacuser" ] && ok "snac token for snacuser" || { ko "snac token"; return 1; }
PT=$(privapub_token alice_snac)
PH="Authorization: Bearer $PT"
[ -n "$PT" ] && ok "PrivaPub token for alice_snac" || { ko "PrivaPub token for alice_snac"; return 1; }
run=$(date +%s)
echo " follows"
alice_on_mi=$(mi "$MI/api/v2/search?q=alice_snac%40privapub.test&resolve=true" | j "print(d['accounts'][0]['id'])")
[ -n "$alice_on_mi" ] && ok "snac resolves alice" || ko "snac cannot resolve alice"
mi -o /dev/null -X POST "$MI/api/v1/accounts/$alice_on_mi/follow"
until_true 45 '[ "$(mi "$MI/api/v1/accounts/relationships?id[]=$alice_on_mi" | j "print(d[0][\"following\"])")" = "True" ]' \
&& ok "snacuser follows alice (Accept arrived)" || ko "alice's Accept never reached snac"
mi_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=snacuser@snac.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ -n "$mi_on_p" ] && ok "PrivaPub resolves snacuser" || ko "PrivaPub cannot resolve snacuser"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$mi_on_p/follow"
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$mi_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \
&& ok "alice follows snacuser (Accept arrived)" || ko "snac's Accept never arrived"
echo " posts"
mi_post=$(mi_json POST /api/v1/statuses "{\"status\":\"a snac post $run\",\"visibility\":\"public\"}")
mi_post_id=$(echo "$mi_post" | j "print(d['id'])")
until_true 45 '[ -n "$(p_home_has "a snac post $run")" ]' && ok "snacuser's post reaches alice's home" || ko "snacuser's post never reached alice"
mi_on_p_post=$(p_home_has "a snac post $run")
p_post=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=a PrivaPub post for snac $run&visibility=public")
p_post_id=$(echo "$p_post" | j "print(d['id'])"); p_post_uri=$(echo "$p_post" | j "print(d['uri'])")
until_true 45 '[ "$(mi "$MI/api/v1/timelines/home?limit=40" | j "print(any(s[\"uri\"] == \"$p_post_uri\" for s in d))")" = "True" ]' \
&& ok "alice's post reaches snacuser's home" || ko "alice's post never reached snac"
p_on_mi=$(mi "$MI/api/v1/timelines/home?limit=40" | j "print(next(s['id'] for s in d if s['uri'] == '$p_post_uri'))")
echo " replies"
mi_json POST /api/v1/statuses "{\"status\":\"@alice_snac@privapub.test a snac reply $run\",\"in_reply_to_id\":\"$p_on_mi\",\"visibility\":\"public\"}" >/dev/null
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id/context" | j "print(any(\"a snac reply $run\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \
&& ok "snacuser's reply threads under alice's post" || ko "snacuser's reply missing on PrivaPub (replying to ${p_on_mi:-nothing})"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d "status=@snacuser@snac.test a PrivaPub reply $run&in_reply_to_id=$mi_on_p_post&visibility=public"
until_true 45 '[ "$(mi "$MI/api/v1/statuses/$mi_post_id/context" | j "print(any(\"a PrivaPub reply $run\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \
&& ok "alice's reply threads under snacuser's post" || ko "alice's reply missing on snac"
echo " likes and boosts"
mi -o /dev/null -X POST "$MI/api/v1/statuses/$p_on_mi/favourite"
until_true 45 '[ "$(p_status $p_post_id favourites_count)" = "1" ]' && ok "snacuser's like counts on PrivaPub" || ko "snacuser's like never counted"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$mi_on_p_post/favourite"
until_true 45 '[ "$(mi_status_of $mi_post_id | j "print(d[\"favourites_count\"])")" = "1" ]' && ok "alice's like counts on snac" || ko "alice's like never counted on snac"
mi -o /dev/null -X POST "$MI/api/v1/statuses/$p_on_mi/reblog"
until_true 45 '[ "$(p_status $p_post_id reblogs_count)" = "1" ]' && ok "snacuser's repost is a boost on PrivaPub" || ko "snacuser's repost never counted"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$mi_on_p_post/reblog"
until_true 45 '[ "$(mi_status_of $mi_post_id | j "print(d[\"reblogs_count\"])")" = "1" ]' && ok "alice's boost counts on snac" || ko "alice's boost never counted on snac"
echo " polls"
# (its API reads a poll from form fields only)
mi_poll=$(mi -X POST "$MI/api/v1/statuses" -d "status=a snac poll $run&visibility=public&poll[options][]=hay&poll[options][]=clover&poll[expires_in]=3600")
until_true 45 '[ -n "$(p_home_has "a snac poll $run")" ]' && ok "snacuser's poll reaches alice" || ko "snacuser's poll never reached alice"
p_poll=$(curl -s -H "$PH" "$P/api/v1/statuses/$(p_home_has "a snac poll $run")" | j "print(d['poll']['id'] if d.get('poll') else '')")
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/polls/$p_poll/votes" -d 'choices[]=1'
until_true 45 '[ "$(mi_status_of $(echo "$mi_poll" | j "print(d[\"id\"])") | j "print(d[\"poll\"][\"votes_count\"])")" = "1" ]' \
&& ok "alice's vote counts on snac" || ko "alice's vote never counted on snac"
echo " edits and deletions"
mi_json PUT "/api/v1/statuses/$mi_post_id" "{\"status\":\"a snac post $run, edited\"}" >/dev/null
until_true 45 '[ "$(p_status $mi_on_p_post content | grep -c edited)" = "1" ]' && ok "snacuser's edit reaches PrivaPub" || ko "snacuser's edit never arrived"
curl -s -o /dev/null -X PUT -H "$PH" "$P/api/v1/statuses/$p_post_id" -d "status=a PrivaPub post for snac $run, edited"
# (snac works through what it receives one message at a time, sometimes minutes behind)
until_true 120 '[ "$(snac_stored "$p_post_uri" "o is not None and \"edited\" in o[\"content\"]")" = "True" ]' && ok "alice's edit reaches snac" || ko "alice's edit never reached snac ($(snac_stored "$p_post_uri" "o and o['content'][-60:]"))"
gone=$(mi_json POST /api/v1/statuses "{\"status\":\"a snac post to delete $run\",\"visibility\":\"public\"}" | j "print(d['id'])")
until_true 45 '[ -n "$(p_home_has "a snac post to delete $run")" ]'
gone_on_p=$(p_home_has "a snac post to delete $run")
mi -o /dev/null -X DELETE "$MI/api/v1/statuses/$gone"
until_true 45 '[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$PH" "$P/api/v1/statuses/$gone_on_p")" = "404" ]' \
&& ok "snacuser's deletion reaches PrivaPub" || ko "snacuser's deleted post still shows on PrivaPub"
curl -s -o /dev/null -X DELETE -H "$PH" "$P/api/v1/statuses/$p_post_id"
# (snac keeps the object and takes it out of snacuser's timeline)
until_true 45 '! snac_in_timeline "$p_post_uri"' \
&& ok "alice's deletion reaches snac" || ko "alice's deleted post still on snac"
echo " direct messages"
mi_json POST /api/v1/statuses "{\"status\":\"@alice_snac@privapub.test a snac secret $run\",\"visibility\":\"direct\"}" >/dev/null
until_true 45 'curl -s -H "$PH" "$P/api/v1/conversations" | grep -q "a snac secret $run"' && ok "snacuser's DM arrives" || ko "snacuser's DM never arrived"
dm_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=@snacuser@snac.test a PrivaPub secret $run&visibility=direct" | j "print(d['uri'])")
until_true 120 '[ "$(snac_stored "$dm_uri" "o is not None and \"a PrivaPub secret\" in o[\"content\"]")" = "True" ]' \
&& ok "alice's DM reaches snac" || ko "alice's DM never reached snac"
echo " unfollow"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$mi_on_p/unfollow"
until_true 45 '[ "$(mi "$MI/api/v1/accounts/relationships?id[]=$alice_on_mi" | j "print(d[0][\"followed_by\"])")" = "False" ]' \
&& ok "alice's unfollow reaches snac" || ko "snac still counts alice as a follower"
echo " statistics"
stats_check snac.test snac