From e29da1b47a6dc6542b2afc91e3229d6b41db715f Mon Sep 17 00:00:00 2001 From: thepra Date: Sat, 3 Oct 2026 13:29:17 +0200 Subject: [PATCH] T12: Misskey 2026.10 in the pasture peers/misskey.sh runs Misskey on the shared Postgres and Redis. Its config is generated with the pasture's private networks allowed and a setup password, it trusts Caddy's CA through NODE_EXTRA_CA_CERTS, and the first account it makes is the scenario's user. A new Misskey federates with nobody, so the setup also turns federation on. scenarios/misskey.sh adds 35 checks, all passing, as listed in docs/INTEROP.md. They cover posts, CW, MFM source, replies, unicode reactions both ways and their withdrawal, likes as reactions, legacy quotes both ways, polls, specified notes, media, deletes, unfollow, block and statistics. MISSKEY_NAME and MISSKEY_IMAGE are there so Sharkey can reuse the peer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2 --- CLAUDE.md | 4 + docs/INTEROP.md | 15 ++++ tools/pasture/Caddyfile | 15 ++++ tools/pasture/lib/pasture.sh | 2 + tools/pasture/peers/misskey.sh | 48 +++++++++++ tools/pasture/scenarios/misskey.sh | 124 +++++++++++++++++++++++++++++ 6 files changed, 208 insertions(+) create mode 100644 tools/pasture/peers/misskey.sh create mode 100644 tools/pasture/scenarios/misskey.sh diff --git a/CLAUDE.md b/CLAUDE.md index 2c1ae90..d866d53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -410,6 +410,10 @@ tools/pasture/run.sh down # removes e deliveries are checked through `/api/v1/accounts/:id/statuses` of the sender as Mastodon knows them, or `Status.exists?` through `rails runner`. Its actors are numbered (`/ap/users/`), so look URIs up rather than build them. 49 checks; circle posts are an expected failure (see `docs/INTEROP.md`, Mastodon). +- **Misskey (2026.10.0):** one container on the shared Postgres and Redis. A new Misskey federates with nobody + (`federation: none`) until `admin/update-meta` says `all`, which `misskey_up` does. Its API is `POST /api/` + with the token as `i` (`mk` in the scenario); `users/relation` answers a list, `users/notes` leaves replies out unless + asked, and a user has one reaction per note. Never verify a delivery with `ap/show`: it fetches. 35 checks. - **Crawler:** `PRIVAPUB_ENV="Statistics__Crawler__Enabled=true Statistics__Crawler__Seeds__0=mastodon.test" run.sh up mastodon`, then `interop.sh crawler`. `PRIVAPUB_ENV` passes any setting to the PrivaPub container. - `run.sh up` replaces every container, Mongo included, so each run starts clean. To keep the data, republish into diff --git a/docs/INTEROP.md b/docs/INTEROP.md index c39bc5d..3e2c37f 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -310,6 +310,21 @@ Firefish is dead (its site has answered 410 since February 2025). | Per-attachment `sensitive`; `isCat` and other actor extras; enforce `requireSignin…` and `makeNotes…Before` on our public pages | P2 | own `privapub.*` | | Quotes: when we send one, send every key (`quote`, `_misskey_quote`, `quoteUrl`, `quoteUri`, a FEP-e232 tag, the `RE:` fallback) | P2 | — | +**Pasture evidence (2026-10-03, Misskey 2026.10.0, `tools/pasture/scenarios/misskey.sh`):** 35 checks pass: +- discovery and follows both ways; +- posts, CW and the MFM `source` kept; +- replies threading both ways; +- 👍 and 🎉 reactions both ways, and their withdrawal; +- a like counting as a reaction; +- legacy quotes both ways (`_misskey_quote` in, `renoteId` out); +- polls and votes both ways; +- `specified` notes as DMs both ways; +- images with alt text (`comment`) both ways; +- deletes both ways, unfollow and block; +- statistics. + +A new Misskey 2026 starts with `federation: none`. + ### Pleroma 2.10.2 and Akkoma 3.20.1 **Emits** diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index 528e9fa..873a78e 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -17,3 +17,18 @@ mastodon.test { tls internal reverse_proxy pasture-mastodon:3000 } + +misskey.test { + tls internal + reverse_proxy pasture-misskey:3000 +} + +sharkey.test { + tls internal + reverse_proxy pasture-sharkey:3000 +} + +lemmy.test { + tls internal + reverse_proxy pasture-lemmy:8536 +} diff --git a/tools/pasture/lib/pasture.sh b/tools/pasture/lib/pasture.sh index ca1bda4..f4a4d3c 100644 --- a/tools/pasture/lib/pasture.sh +++ b/tools/pasture/lib/pasture.sh @@ -2,6 +2,8 @@ here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; repo="$(cd "$here/../.." && pwd)" net=privapub-pasture; publish="$here/.publish"; ca="$here/.ca" +site() { curl -k --resolve "$1:6443:127.0.0.1" "${@:2}"; } + wait_http() { # url tries for _ in $(seq 1 "${2:-60}"); do curl -fsk -o /dev/null "$1" && return 0; sleep 2; done echo "timed out waiting for $1" >&2; return 1 diff --git a/tools/pasture/peers/misskey.sh b/tools/pasture/peers/misskey.sh new file mode 100644 index 0000000..ef940ef --- /dev/null +++ b/tools/pasture/peers/misskey.sh @@ -0,0 +1,48 @@ +# Misskey: one container on the shared Postgres (database misskey) and Redis (db 2). The first account, made with the +# setup password, is the scenario's user; its token is kept in .state/misskey.token. +MISSKEY_IMAGE=${MISSKEY_IMAGE:-docker.io/misskey/misskey:2026.10.0} +MISSKEY_NAME=${MISSKEY_NAME:-misskey} +. "$here/peers/shared.sh" + +misskey_up() { + shared_postgres_up + shared_redis_up + podman exec pasture-postgres sh -c "psql -U pasture -tc \"select 1 from pg_database where datname='$MISSKEY_NAME'\" | grep -q 1 || createdb -U pasture $MISSKEY_NAME" + mkdir -p "$here/.state/$MISSKEY_NAME" + cat > "$here/.state/$MISSKEY_NAME/default.yml" </dev/null + for _ in $(seq 1 120); do + site "$MISSKEY_NAME.test" -s -o /dev/null -w '%{http_code}' -X POST "https://$MISSKEY_NAME.test:6443/api/meta" -H 'Content-Type: application/json' -d '{}' 2>/dev/null | grep -q 200 && break + sleep 3 + done + site "$MISSKEY_NAME.test" -s -X POST "https://$MISSKEY_NAME.test:6443/api/admin/accounts/create" -H 'Content-Type: application/json' \ + -d '{"username":"mkuser","password":"Misskey-Pasture-Pass-1","setupPassword":"pasture-setup"}' \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])" > "$here/.state/$MISSKEY_NAME.token" 2>/dev/null || true + # a new Misskey federates with nobody (federation: none) until its admin says otherwise + site "$MISSKEY_NAME.test" -s -o /dev/null -X POST "https://$MISSKEY_NAME.test:6443/api/admin/update-meta" -H 'Content-Type: application/json' \ + -d "{\"i\":\"$(cat "$here/.state/$MISSKEY_NAME.token")\",\"federation\":\"all\"}" + echo "$MISSKEY_NAME: https://$MISSKEY_NAME.test:6443" +} diff --git a/tools/pasture/scenarios/misskey.sh b/tools/pasture/scenarios/misskey.sh new file mode 100644 index 0000000..de85b1a --- /dev/null +++ b/tools/pasture/scenarios/misskey.sh @@ -0,0 +1,124 @@ +# Misskey 2026.10 (or Sharkey, with MISSKEY_NAME=sharkey): discovery, follows, posts and CW, replies both ways, Misskey +# reactions both ways, legacy quotes both ways, MFM source, polls, specified notes, media, deletes, blocks, statistics. +# A delivery is never checked with ap/show, which would fetch it from us; only with what Misskey already holds. +MK_NAME=${MISSKEY_NAME:-misskey} +MKH="$MK_NAME.test" +MK="https://$MKH:6443" +MKT=$(cat "$here/.state/$MK_NAME.token" 2>/dev/null) +# mk [json]: a Misskey API call as mkuser +mk() { + local body=${2:-'{}'} + site "$MKH" -s -X POST "$MK/api/$1" -H 'Content-Type: application/json' \ + -d "$(python3 -c "import sys,json; d=json.loads(sys.argv[1]); d['i']=sys.argv[2]; print(json.dumps(d))" "$body" "$MKT")" +} +# the notes Misskey holds of an account, and the one whose uri is given +mk_notes() { mk users/notes "{\"userId\":\"$1\",\"limit\":50,\"withReplies\":true,\"withRenotes\":true}"; } +# users/relation answers a list even for one user +mk_relation() { mk users/relation "{\"userId\":\"$1\"}" | j "r=d[0] if isinstance(d,list) else d; print(r['$2'])"; } +mk_note_by_uri() { mk_notes "$1" | j "print(json.dumps(next((n for n in d if n.get('uri')=='$2'), None)))"; } + +echo "$MK_NAME" +[ -n "$MKT" ] && ok "$MK_NAME token for mkuser" || { ko "$MK_NAME token"; return 1; } +KT=$(privapub_token "alice_$MK_NAME") +KH="Authorization: Bearer $KT" +[ -n "$KT" ] && ok "PrivaPub token for alice_$MK_NAME" || { ko "PrivaPub token for alice_$MK_NAME"; return 1; } + +echo " discovery" +alice_on_mk=$(mk users/show "{\"username\":\"alice_$MK_NAME\",\"host\":\"privapub.test\"}" | j "print(d['id'])") +[ -n "$alice_on_mk" ] && ok "$MK_NAME resolves @alice_$MK_NAME@privapub.test" || ko "$MK_NAME cannot resolve alice" +mk_on_p=$(curl -s -H "$KH" "$P/api/v2/search?q=mkuser@$MKH&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +[ -n "$mk_on_p" ] && ok "PrivaPub resolves @mkuser@$MKH" || ko "PrivaPub cannot resolve mkuser" + +echo " follows" +mk following/create "{\"userId\":\"$alice_on_mk\"}" >/dev/null +until_true 30 '[ "$(mk_relation "$alice_on_mk" isFollowing)" = "True" ]' && ok "mkuser follows alice (Accept arrived)" || ko "$MK_NAME's follow not accepted" +curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/follow" +until_true 30 '[ "$(curl -s -H "$KH" "$P/api/v1/accounts/relationships?id[]=$mk_on_p" | j "print(d[0][\"following\"])")" = "True" ]' && ok "alice follows mkuser (Accept arrived)" || ko "PrivaPub's follow not accepted" + +echo " posts" +k_post=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=Hello $MK_NAME from PrivaPub&visibility=public") +k_post_id=$(echo "$k_post" | j "print(d['id'])"); k_post_uri=$(echo "$k_post" | j "print(d['uri'])") +until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_post_uri")" != "null" ]' && ok "alice's post reaches $MK_NAME" || ko "post missing on $MK_NAME" +k_post_on_mk=$(mk_note_by_uri "$alice_on_mk" "$k_post_uri" | j "print(d['id'])") +mk_post=$(mk notes/create "{\"text\":\"Hello PrivaPub from $MK_NAME, \$[x2 big] words\",\"visibility\":\"public\"}" | j "print(d['createdNote']['id'])") +until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | grep -q "Hello PrivaPub from $MK_NAME"' && ok "mkuser's post reaches alice's home" || ko "$MK_NAME's post missing on PrivaPub" +mk_post_on_p=$(curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'Hello PrivaPub from $MK_NAME' in s['content']))") +[ "$(curl -s -H "$KH" "$P/api/v1/statuses/$mk_post_on_p" | j "print('\$[x2' in ((d.get('privapub') or {}).get('source') or {}).get('content',''))")" = "True" ] \ + && ok "the MFM source is kept" || ko "MFM source lost" +cw_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d 'status=behind a warning&spoiler_text=spoilers&visibility=public' | j "print(d['uri'])") +until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$cw_uri" | j "print(d and d[\"cw\"])")" = "spoilers" ]' && ok "a content warning survives to $MK_NAME" || ko "content warning lost on $MK_NAME" + +echo " replies" +mk notes/create "{\"text\":\"@alice_$MK_NAME@privapub.test replying from $MK_NAME\",\"replyId\":\"$k_post_on_mk\",\"visibility\":\"public\"}" >/dev/null +until_true 30 'curl -s -H "$KH" "$P/api/v1/notifications" | j "print(any(n[\"type\"]==\"mention\" for n in d))" | grep -q True' && ok "mkuser's reply notifies alice" || ko "reply did not notify" +until_true 15 '[ "$(curl -s -H "$KH" "$P/api/v1/statuses/$k_post_id/context" | j "print(len(d[\"descendants\"]))")" -ge 1 ]' && ok "the reply threads under alice's post" || ko "reply not threaded on PrivaPub" +k_reply_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=@mkuser@$MKH replying from PrivaPub&in_reply_to_id=$mk_post_on_p&visibility=public" | j "print(d['uri'])") +until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_reply_uri" | j "print(d and d[\"replyId\"])")" = "$mk_post" ]' && ok "alice's reply threads under mkuser's note" || ko "outbound reply not threaded on $MK_NAME" + +echo " reactions" +mk notes/reactions/create "{\"noteId\":\"$k_post_on_mk\",\"reaction\":\"👍\"}" >/dev/null +until_true 30 'curl -s -H "$KH" "$P/api/v1/pleroma/statuses/$k_post_id/reactions" | j "print(any(r[\"name\"]==\"👍\" and r[\"count\"]==1 for r in d))" | grep -q True' && ok "mkuser's 👍 reaction reaches PrivaPub" || ko "reaction missing on PrivaPub" +mk notes/reactions/delete "{\"noteId\":\"$k_post_on_mk\"}" >/dev/null +until_true 30 '[ "$(curl -s -H "$KH" "$P/api/v1/pleroma/statuses/$k_post_id/reactions" | j "print(len(d))")" = "0" ]' && ok "mkuser's withdrawn reaction leaves PrivaPub" || ko "reaction not withdrawn on PrivaPub" +curl -s -o /dev/null -X PUT -H "$KH" "$P/api/v1/pleroma/statuses/$mk_post_on_p/reactions/%F0%9F%8E%89" +until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$mk_post\"}" | j "print(d[\"reactions\"].get(\"🎉\", 0))")" = "1" ]' && ok "alice's 🎉 reaction reaches $MK_NAME" || ko "reaction missing on $MK_NAME" +# Misskey keeps one reaction per user and note, so the like goes to a note of its own +mk_liked=$(mk notes/create "{\"text\":\"like this one, PrivaPub\",\"visibility\":\"public\"}" | j "print(d['createdNote']['id'])") +until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | grep -q "like this one, PrivaPub"' || true +mk_liked_on_p=$(curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'like this one, PrivaPub' in s['content']))") +curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/statuses/$mk_liked_on_p/favourite" +until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$mk_liked\"}" | j "print(sum(d[\"reactions\"].values()))")" = "1" ]' && ok "alice's like counts as a reaction on $MK_NAME" || ko "like not counted on $MK_NAME" + +echo " quotes" +mk_quote=$(mk notes/create "{\"text\":\"quoting PrivaPub\",\"renoteId\":\"$k_post_on_mk\",\"visibility\":\"public\"}" | j "print(d['createdNote']['id'])") +until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(any(\"quoting PrivaPub\" in s[\"content\"] and (s.get(\"quote\") or {}).get(\"state\")==\"accepted\" for s in d))" | grep -q True' \ + && ok "mkuser's quote arrives as an accepted quote" || ko "$MK_NAME's quote not understood" +k_quote_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=quoting $MK_NAME&visibility=public"ed_status_id=$mk_post_on_p" | j "print(d['uri'])") +until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_quote_uri" | j "print(d and d[\"renoteId\"])")" = "$mk_post" ]' && ok "alice's quote is a quote on $MK_NAME" || ko "quote not understood by $MK_NAME" + +echo " polls" +k_poll_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d 'status=cats or dogs&visibility=public&poll[options][]=cats&poll[options][]=dogs&poll[expires_in]=3600' | j "print(d['uri'])") +until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_poll_uri" | j "print(len(d[\"poll\"][\"choices\"]))")" = "2" ]' && ok "alice's poll reaches $MK_NAME as a poll" || ko "poll missing on $MK_NAME" +k_poll_on_mk=$(mk_note_by_uri "$alice_on_mk" "$k_poll_uri" | j "print(d['id'])") +mk notes/polls/vote "{\"noteId\":\"$k_poll_on_mk\",\"choice\":1}" >/dev/null +alice_self=$(curl -s -H "$KH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])") +until_true 30 '[ "$(curl -s -H "$KH" "$P/api/v1/accounts/$alice_self/statuses" | j "print(next(s[\"poll\"][\"options\"][1][\"votes_count\"] for s in d if s[\"uri\"]==\"$k_poll_uri\"))")" = "1" ]' && ok "mkuser's vote counts on PrivaPub" || ko "vote not counted on PrivaPub" +mk_poll=$(mk notes/create "{\"text\":\"tea or coffee, $MK_NAME asks\",\"visibility\":\"public\",\"poll\":{\"choices\":[\"tea\",\"coffee\"],\"expiredAfter\":3600000}}" | j "print(d['createdNote']['id'])") +until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(any(s[\"poll\"] and \"$MK_NAME asks\" in s[\"content\"] for s in d))" | grep -q True' && ok "mkuser's poll reaches PrivaPub as a poll" || ko "poll missing on PrivaPub" +mk_poll_on_p=$(curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(next(s['poll']['id'] for s in d if s['poll'] and '$MK_NAME asks' in s['content']))") +curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/polls/$mk_poll_on_p/votes" -d 'choices[]=0' +until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$mk_poll\"}" | j "print(d[\"poll\"][\"choices\"][0][\"votes\"])")" = "1" ]' && ok "alice's vote counts on $MK_NAME" || ko "vote not counted on $MK_NAME" + +echo " specified notes" +mk notes/create "{\"text\":\"@alice_$MK_NAME@privapub.test a secret for PrivaPub\",\"visibility\":\"specified\",\"visibleUserIds\":[\"$alice_on_mk\"]}" >/dev/null +until_true 30 'curl -s -H "$KH" "$P/api/v1/conversations" | grep -q "a secret for PrivaPub"' && ok "mkuser's specified note arrives as a DM" || ko "specified note missing on PrivaPub" +curl -s -o /dev/null -X POST -H "$KH" $P/api/v1/statuses -d "status=@mkuser@$MKH a secret for $MK_NAME&visibility=direct" +until_true 30 'mk notes/mentions "{\"visibility\":\"specified\"}" | grep -q "a secret for $MK_NAME"' && ok "alice's DM arrives as a specified note" || ko "DM missing on $MK_NAME" + +echo " media" +make_png "$work/red.png" +k_media=$(curl -s -X POST -H "$KH" "$P/api/v2/media" -F "file=@$work/red.png;type=image/png" -F 'description=a red square' | j "print(d['id'])") +k_media_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=a picture for $MK_NAME&visibility=public&media_ids[]=$k_media" | j "print(d['uri'])") +until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_media_uri" | j "print(d[\"files\"][0][\"comment\"])")" = "a red square" ]' && ok "an image with alt text reaches $MK_NAME" || ko "image or alt text missing on $MK_NAME" +mk_file=$(site "$MKH" -s -X POST "$MK/api/drive/files/create" -F "i=$MKT" -F "file=@$work/red.png;type=image/png" -F "comment=a red square from $MK_NAME" | j "print(d['id'])") +mk notes/create "{\"text\":\"a picture from $MK_NAME\",\"visibility\":\"public\",\"fileIds\":[\"$mk_file\"]}" >/dev/null +until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(any(\"a picture from $MK_NAME\" in s[\"content\"] and s[\"media_attachments\"] and \"/media/proxy/\" in s[\"media_attachments\"][0][\"url\"] and s[\"media_attachments\"][0][\"description\"]==\"a red square from $MK_NAME\" for s in d))" | grep -q True' \ + && ok "an image with alt text from $MK_NAME arrives through our proxy" || ko "$MK_NAME's image missing, unproxied or without alt text" + +echo " deletes" +cw_on_mk=$(mk_note_by_uri "$alice_on_mk" "$cw_uri" | j "print(d['id'])") +cw_id=$(curl -s -H "$KH" "$P/api/v1/accounts/$alice_self/statuses" | j "print(next(s['id'] for s in d if s['uri']=='$cw_uri'))") +curl -s -o /dev/null -X DELETE -H "$KH" "$P/api/v1/statuses/$cw_id" +until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$cw_on_mk\"}" | j "print(d.get(\"error\",{}).get(\"code\"))")" = "NO_SUCH_NOTE" ]' && ok "alice's delete reaches $MK_NAME" || ko "delete not applied on $MK_NAME" +mk notes/delete "{\"noteId\":\"$mk_poll\"}" >/dev/null +until_true 30 '! curl -s -H "$KH" "$P/api/v1/timelines/home" | grep -q "$MK_NAME asks"' && ok "mkuser's delete reaches PrivaPub" || ko "$MK_NAME's delete not applied on PrivaPub" + +echo " unfollows and blocks" +curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/unfollow" +until_true 30 '[ "$(mk_relation "$alice_on_mk" isFollowed)" = "False" ]' && ok "alice's unfollow reaches $MK_NAME" || ko "unfollow not applied on $MK_NAME" +curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/block" +until_true 30 '[ "$(mk_relation "$alice_on_mk" isBlocked)" = "True" ]' && ok "alice's block reaches $MK_NAME" || ko "block not applied on $MK_NAME" +curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/unblock" + +echo " statistics" +stats_check "$MKH" "$MK_NAME"