From 1c0dfeb9a4a214aaeba23a4980d5ef968db554fa Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 23:35:35 +0200 Subject: [PATCH] Pasture: Mitra 5.9.1 Mitra joins the pasture (peers/mitra.sh) on the shared Postgres; scenarios/mitra.sh drives its Mastodon API through follows, posts, replies, likes, reposts, a reaction, a poll, edits, deletions, direct messages, the unfollow and statistics: 28 checks, with no change to PrivaPub. Mitra carries FEP-8b32 proofs made with Ed25519, which PrivaPub does not verify yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 3 + FEDERATION.md | 1 + docs/INTEROP.md | 11 ++++ tools/pasture/Caddyfile | 5 ++ tools/pasture/peers/mitra.sh | 43 ++++++++++++++ tools/pasture/scenarios/mitra.sh | 99 ++++++++++++++++++++++++++++++++ 6 files changed, 162 insertions(+) create mode 100644 tools/pasture/peers/mitra.sh create mode 100644 tools/pasture/scenarios/mitra.sh diff --git a/CLAUDE.md b/CLAUDE.md index 34dd754..cefa6b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -561,6 +561,9 @@ tools/pasture/run.sh down # removes e checks. - **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. +- **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 + elsewhere only without `type`. `scenarios/mitra.sh`, 28 checks. - **Relays (`peers/relay.sh` Activity-Relay 2.0.9 as `relay.test`, on the shared Redis's database 13; `peers/aoderelay.sh` aode-relay 0.3.129 as `aoderelay.test`):** `appsettings.Pasture.json` names both in `Federation:Relays`, so PrivaPub subscribes a minute after it starts. `scenarios/relay.sh` has Mastodon subscribe to diff --git a/FEDERATION.md b/FEDERATION.md index 2aa9a4f..edc3665 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -33,6 +33,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d - **Mbin 1.10.1** - **NodeBB 4.16.1** - **Smithereen 1.0.3** +- **Mitra 5.9.1** - **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**, **Pleroma 2.10.2** diff --git a/docs/INTEROP.md b/docs/INTEROP.md index d8726c7..e1f9da4 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -753,6 +753,17 @@ 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, without a port, before it gives out its OAuth client. +### 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 + PrivaPub does not verify yet; the HTTP signature is enough for what it delivers itself. +- Its Mastodon API resolves an account elsewhere only when the search is not limited to a type + (`/api/v2/search?resolve=true`, no `type=accounts`); reactions go through Pleroma's route + (`PUT /api/v1/pleroma/statuses/:id/reactions/:emoji`). +- **Pasture evidence (2026-10-05, `tools/pasture/scenarios/mitra.sh`):** 28 checks pass, with no change to PrivaPub: + follows both ways, posts, replies both ways, likes and reposts both ways, an emoji reaction, a poll and alice's vote, + edits and deletions both ways, direct messages both ways, the unfollow, statistics. + ### Relays: Activity-Relay 2.0.9 and aode-relay 0.3.129 - **Activity-Relay** takes a subscription as a `Follow` of `Public` from an actor with a shared inbox (Mastodon's way) diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index b0e746f..6b0d4e6 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -108,6 +108,11 @@ nodebb.test { reverse_proxy pasture-nodebb:4567 } +mitra.test { + tls internal + reverse_proxy pasture-mitra:8383 +} + aoderelay.test { tls internal reverse_proxy pasture-aoderelay:8080 diff --git a/tools/pasture/peers/mitra.sh b/tools/pasture/peers/mitra.sh new file mode 100644 index 0000000..f403e44 --- /dev/null +++ b/tools/pasture/peers/mitra.sh @@ -0,0 +1,43 @@ +# Mitra 5.9.1 (silverpill): a microblog in Rust with Ed25519 keys beside RSA (FEP-521a), integrity proofs (FEP-8b32), +# nomadic identity (FEP-ef61) and account moves. On the shared Postgres (database mitra), as mitra.test; its TLS client +# reads the system's roots, over which the pasture's bundle goes. mitrauser is made by its CLI, and its Mastodon API +# takes a password grant. +MITRA_IMAGE=${MITRA_IMAGE:-codeberg.org/silverpill/mitra:5.9.1} +MITRA_PASSWORD=Mitra-Pasture-Pass-1 +. "$here/peers/shared.sh" + +mitra_up() { + shared_postgres_up + pg_db mitra + local st="$here/.state/mitra" + mkdir -p "$st" + cat > "$st/config.yaml" <<-EOF2 + database_url: postgres://pasture:pasture@postgres:5432/mitra + storage_dir: /var/lib/mitra + web_client_dir: /usr/share/mitra/www + http_host: '0.0.0.0' + http_port: 8383 + instance_url: https://mitra.test + instance_title: Mitra pasture + instance_short_description: the pasture's Mitra + instance_description: The pasture's Mitra + instance_staff_public: true + instance_timeline_public: true + registration: + type: open + default_role: user + EOF2 + podman volume exists pasture-mitra || podman volume create --label pasture=1 pasture-mitra >/dev/null + podman run -d --replace --name pasture-mitra --label pasture=1 --network $net -v pasture-mitra:/var/lib/mitra:U \ + -v "$st/config.yaml:/etc/mitra/config.yaml:Z,ro" -v "$ca/bundle.pem:/etc/ssl/certs/ca-certificates.crt:z,ro" \ + $MITRA_IMAGE >/dev/null + for _ in $(seq 1 60); do + site mitra.test -s -o /dev/null -w '%{http_code}' https://mitra.test:6443/api/v1/instance 2>/dev/null | grep -q 200 && break + sleep 1 + done + podman exec pasture-mitra mitra create-account mitrauser "$MITRA_PASSWORD" user >/dev/null 2>&1 || true + site mitra.test -s -X POST https://mitra.test:6443/oauth/token -H 'Content-Type: application/json' \ + -d "{\"grant_type\":\"password\",\"username\":\"mitrauser\",\"password\":\"$MITRA_PASSWORD\"}" \ + | python3 -c 'import json, sys; print(json.load(sys.stdin).get("access_token", ""))' > "$st/mitrauser.token" + echo "mitra: https://mitra.test:6443" +} diff --git a/tools/pasture/scenarios/mitra.sh b/tools/pasture/scenarios/mitra.sh new file mode 100644 index 0000000..cf717de --- /dev/null +++ b/tools/pasture/scenarios/mitra.sh @@ -0,0 +1,99 @@ +# Mitra 5.9.1: follows both ways, posts, replies, likes, reposts, an emoji reaction, a poll, an edit and a deletion both +# ways, direct messages, the unfollow, statistics. Mitra signs its deliveries with RSA and carries FEP-8b32 proofs made +# with its Ed25519 key. Driven through its Mastodon API as mitrauser, with the token peers/mitra.sh got. +MI=https://mitra.test:6443 +MIT=$(cat "$here/.state/mitra/mitrauser.token" 2>/dev/null) +mi() { site mitra.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}"; } +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 "mitra" +[ "$(mi "$MI/api/v1/accounts/verify_credentials" | j "print(d['username'])")" = "mitrauser" ] && ok "Mitra token for mitrauser" || { ko "Mitra token"; return 1; } +PT=$(privapub_token alice_mitra) +PH="Authorization: Bearer $PT" +[ -n "$PT" ] && ok "PrivaPub token for alice_mitra" || { ko "PrivaPub token for alice_mitra"; return 1; } +run=$(date +%s) + +echo " follows" +# (its search resolves an account elsewhere only when it is not limited to a type) +alice_on_mi=$(mi "$MI/api/v2/search?q=alice_mitra%40privapub.test&resolve=true" | j "print(d['accounts'][0]['id'])") +[ -n "$alice_on_mi" ] && ok "Mitra resolves alice" || ko "Mitra 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 "mitrauser follows alice (Accept arrived)" || ko "alice's Accept never reached Mitra" +mi_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=mitrauser@mitra.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +[ -n "$mi_on_p" ] && ok "PrivaPub resolves mitrauser" || ko "PrivaPub cannot resolve mitrauser" +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 mitrauser (Accept arrived)" || ko "Mitra's Accept never arrived" + +echo " posts" +mi_post=$(mi_json POST /api/v1/statuses "{\"status\":\"a Mitra post $run\",\"visibility\":\"public\"}") +mi_post_id=$(echo "$mi_post" | j "print(d['id'])") +until_true 45 '[ -n "$(p_home_has "a Mitra post $run")" ]' && ok "mitrauser's post reaches alice's home" || ko "mitrauser's post never reached alice" +mi_on_p_post=$(p_home_has "a Mitra post $run") +p_post=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=a PrivaPub post for Mitra $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 mitrauser's home" || ko "alice's post never reached Mitra" +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_mitra@privapub.test a Mitra 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 Mitra reply $run\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \ + && ok "mitrauser's reply threads under alice's post" || ko "mitrauser's reply missing on PrivaPub" +curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d "status=@mitrauser@mitra.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 mitrauser's post" || ko "alice's reply missing on Mitra" + +echo " likes, reposts and reactions" +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 "mitrauser's like counts on PrivaPub" || ko "mitrauser'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 Mitra" || ko "alice's like never counted on Mitra" +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 "mitrauser's repost is a boost on PrivaPub" || ko "mitrauser'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 Mitra" || ko "alice's boost never counted on Mitra" +mi -o /dev/null -X PUT "$MI/api/v1/pleroma/statuses/$p_on_mi/reactions/%F0%9F%8E%89" +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(any(r[\"name\"] == \"🎉\" for r in (d.get(\"pleroma\") or {}).get(\"emoji_reactions\") or d.get(\"reactions\") or []))")" = "True" ]' \ + && ok "mitrauser's reaction reaches alice's post" || ko "mitrauser's reaction never arrived ($(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print((d.get('pleroma') or {}).get('emoji_reactions'))"))" + +echo " polls" +mi_poll=$(mi_json POST /api/v1/statuses "{\"status\":\"a Mitra poll $run\",\"visibility\":\"public\",\"poll\":{\"options\":[\"hay\",\"clover\"],\"expires_in\":3600}}") +until_true 45 '[ -n "$(p_home_has "a Mitra poll $run")" ]' && ok "mitrauser's poll reaches alice" || ko "mitrauser's poll never reached alice" +p_poll=$(curl -s -H "$PH" "$P/api/v1/statuses/$(p_home_has "a Mitra 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 Mitra" || ko "alice's vote never counted on Mitra" + +echo " edits and deletions" +mi_json PUT "/api/v1/statuses/$mi_post_id" "{\"status\":\"a Mitra post $run, edited\"}" >/dev/null +until_true 45 '[ "$(p_status $mi_on_p_post content | grep -c edited)" = "1" ]' && ok "mitrauser's edit reaches PrivaPub" || ko "mitrauser'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 Mitra $run, edited" +until_true 45 '[ "$(mi_status_of $p_on_mi | j "print(\"edited\" in d[\"content\"])")" = "True" ]' && ok "alice's edit reaches Mitra" || ko "alice's edit never reached Mitra" +gone=$(mi_json POST /api/v1/statuses "{\"status\":\"a Mitra post to delete $run\",\"visibility\":\"public\"}" | j "print(d['id'])") +until_true 45 '[ -n "$(p_home_has "a Mitra post to delete $run")" ]' +gone_on_p=$(p_home_has "a Mitra 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 "mitrauser's deletion reaches PrivaPub" || ko "mitrauser's deleted post still shows on PrivaPub" +curl -s -o /dev/null -X DELETE -H "$PH" "$P/api/v1/statuses/$p_post_id" +until_true 45 '[ "$(mi -o /dev/null -w "%{http_code}" "$MI/api/v1/statuses/$p_on_mi")" = "404" ]' \ + && ok "alice's deletion reaches Mitra" || ko "alice's deleted post still on Mitra" + +echo " direct messages" +mi_json POST /api/v1/statuses "{\"status\":\"@alice_mitra@privapub.test a Mitra secret $run\",\"visibility\":\"direct\"}" >/dev/null +until_true 45 'curl -s -H "$PH" "$P/api/v1/conversations" | grep -q "a Mitra secret $run"' && ok "mitrauser's DM arrives" || ko "mitrauser's DM never arrived" +curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d "status=@mitrauser@mitra.test a PrivaPub secret $run&visibility=direct" +until_true 45 'mi "$MI/api/v1/timelines/direct" "$MI/api/v1/conversations" | grep -q "a PrivaPub secret $run"' && ok "alice's DM reaches Mitra" || ko "alice's DM never reached Mitra" + +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 Mitra" || ko "Mitra still counts alice as a follower" + +echo " statistics" +stats_check mitra.test mitra