Two owner decisions of 2026-10-05, both recorded in ROADMAP: - After a verified Move the personas following the old account follow the new one, in the same lists, and a mute or block of the old account carries over, as Mastodon does it. - A direct message to one account on a server whose NodeInfo names Lemmy before 1.0 or Mbin goes as a ChatMessage, the one place PrivaPub decides by a server's software (invariant 17). G-0008 is closed. Mbin addresses its private messages to the recipient's profile page, so a Create addressed to a persona's /@name now reaches the persona. Checked live: moves 8/8, Lemmy 0.19 30/30, Mbin 26/26 with messages both ways. The software theory runs alone, since every test's peer shares 127.0.0.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
122 lines
11 KiB
Bash
122 lines
11 KiB
Bash
# Mbin 1.10.1: the threadiverse in Symfony. Magazines both ways (FEP-1b12), threads with titles, a Note addressed to a
|
|
# magazine as one of its microblog posts, comments both ways, favourites (Like) and upvotes (Announce) both ways (Mbin
|
|
# sends no downvote), private messages both ways, a moderator's lock and removal, the unfollow, statistics. Mbin is driven
|
|
# through its API as mbuser, with the token peers/mbin_token.py got through the authorization-code flow.
|
|
MB=https://mbin.test:6443
|
|
MT=$(cat "$here/.state/mbin.token" 2>/dev/null)
|
|
# mb <method> <path> [json]: an Mbin API call as mbuser. Mbin names what it makes during a request after the request's
|
|
# host, so the call says mbin.test, without the workstation's port
|
|
mb() {
|
|
local method=$1 path=$2 body=${3:-}
|
|
if [ -n "$body" ]; then
|
|
site mbin.test -s -X "$method" "$MB/api/$path" -H 'Host: mbin.test' -H "Authorization: Bearer $MT" -H 'Content-Type: application/json' -d "$body"
|
|
else
|
|
site mbin.test -s -X "$method" "$MB/api/$path" -H 'Host: mbin.test' -H "Authorization: Bearer $MT"
|
|
fi
|
|
}
|
|
mb_sql() { podman exec pasture-postgres psql -U pasture -d mbin -tAc "$1" 2>/dev/null; }
|
|
# what Mbin finds for a handle or a URL (search/v2 resolves it): the first of its kind, as JSON
|
|
mb_resolve() { mb GET "search/v2?q=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "$1")" | j "print(json.dumps(next((r['$2'] for r in d.get('apResults', []) if r.get('$2')), 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 '') or '$1' in (((s.get('reblog') or s).get('privapub') or {}).get('title') or '')), ''))"; }
|
|
|
|
echo "mbin"
|
|
# Mbin's access token lasts an hour: a stale one is replaced through the same authorization-code flow
|
|
if [ "$(mb GET users/me | j "print(d['username'])")" != "mbuser" ]; then
|
|
python3 "$here/peers/mbin_token.py" Mbin-Pasture-Pass-1 "$here/.state/mbin/client.json" > "$here/.state/mbin.token" 2>"$here/.state/mbin/token.log"
|
|
MT=$(cat "$here/.state/mbin.token" 2>/dev/null)
|
|
fi
|
|
[ -n "$MT" ] && [ "$(mb GET users/me | j "print(d['username'])")" = "mbuser" ] && ok "Mbin token for mbuser" || { ko "Mbin token"; return 1; }
|
|
PT=$(privapub_token alice_mbin)
|
|
PH="Authorization: Bearer $PT"
|
|
[ -n "$PT" ] && ok "PrivaPub token for alice_mbin" || { ko "PrivaPub token for alice_mbin"; return 1; }
|
|
jwt=$(privapub_root)
|
|
alice_id=$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
|
|
run=$(date +%s)
|
|
dogs="mbdogs$run"
|
|
books="books$run"
|
|
|
|
echo " magazines"
|
|
dogs_gid=$(curl -s -X POST $P/clientapi/group/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
|
|
-d "{\"avatarId\":\"$alice_id\",\"userName\":\"$dogs\",\"name\":\"Pasture dogs\",\"description\":\"a PrivaPub community\",\"isCommunity\":true}" | j "print(d['id'])")
|
|
dogs_on_mb=$(mb_resolve "!$dogs@privapub.test" magazine | j "print(d['magazineId'])")
|
|
[ -n "$dogs_on_mb" ] && ok "Mbin resolves a PrivaPub community as a magazine" || ko "Mbin cannot resolve the PrivaPub community"
|
|
dogs_on_p=$(curl -s -H "$PH" "$P/api/v1/accounts/lookup?acct=$dogs" | j "print(d['id'])")
|
|
mb PUT "magazine/$dogs_on_mb/subscribe" >/dev/null
|
|
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/$dogs_on_p" | j "print(d[\"followers_count\"])")" = "1" ]' \
|
|
&& ok "mbuser follows the PrivaPub community" || ko "Mbin's follow of the PrivaPub community never arrived"
|
|
podman exec pasture-mbin php bin/console mbin:magazine:create "$books" --owner=mbuser >/dev/null 2>&1
|
|
books_on_mb=$(mb GET "magazine/name/$books" | j "print(d['magazineId'])")
|
|
books_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=@$books@mbin.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
|
|
[ -n "$books_on_p" ] && ok "PrivaPub resolves an Mbin magazine" || ko "PrivaPub cannot resolve the Mbin magazine"
|
|
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$books_on_p/follow"
|
|
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$books_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \
|
|
&& ok "alice follows the Mbin magazine (Accept arrived)" || ko "the magazine's Accept never arrived"
|
|
|
|
echo " threads"
|
|
mb POST "magazine/$dogs_on_mb/article" "{\"title\":\"An Mbin thread $run\",\"body\":\"posted from Mbin into PrivaPub\",\"lang\":\"en\"}" >/dev/null
|
|
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/$dogs_on_p/statuses" | j "print(any((s.get(\"privapub\") or {}).get(\"title\")==\"An Mbin thread $run\" for s in d))")" = "True" ]' \
|
|
&& ok "an Mbin thread in the PrivaPub community arrives with its title" || ko "Mbin's thread missing or untitled on PrivaPub"
|
|
curl -s -o /dev/null -X POST $P/clientapi/post/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
|
|
-d "{\"avatarId\":\"$alice_id\",\"title\":\"A PrivaPub community thread $run\",\"text\":\"posted into our own community for Mbin\",\"groupId\":\"$dogs_gid\"}"
|
|
until_true 45 '[ "$(mb GET "magazine/$dogs_on_mb/entries?sort=newest" | j "print(any(e[\"title\"]==\"A PrivaPub community thread $run\" for e in d.get(\"items\", [])))")" = "True" ]' \
|
|
&& ok "a titled post in the PrivaPub community reaches Mbin as a thread" || ko "PrivaPub community post missing on Mbin"
|
|
books_thread=$(mb POST "magazine/$books_on_mb/article" "{\"title\":\"Books only $run\",\"body\":\"an Mbin magazine thread\",\"lang\":\"en\"}" | j "print(d['entryId'])")
|
|
until_true 45 '[ -n "$(p_home_has "Books only $run")" ]' && ok "the magazine's announce brings its thread to alice's home" || ko "Mbin magazine thread missing from alice's home"
|
|
books_thread_on_p=$(p_home_has "Books only $run")
|
|
p_note=$(curl -s -X POST -H "$PH" $P/api/v1/statuses --data-urlencode "status=@$books@mbin.test a PrivaPub note for the magazine $run" -d 'visibility=public')
|
|
p_note_id=$(echo "$p_note" | j "print(d['id'])")
|
|
mb_post_of() { mb GET "magazine/$books_on_mb/posts?sort=newest" | j "print(next((p['postId'] for p in d.get('items', []) if '$1' in (p.get('body') or '')), ''))"; }
|
|
until_true 45 '[ -n "$(mb_post_of "a PrivaPub note for the magazine $run")" ]' && ok "alice's note lands in the magazine as a microblog post" || ko "PrivaPub note missing from the Mbin magazine"
|
|
p_note_on_mb=$(mb_post_of "a PrivaPub note for the magazine $run")
|
|
|
|
echo " comments"
|
|
mb POST "posts/$p_note_on_mb/comments" "{\"body\":\"an Mbin comment on PrivaPub $run\",\"lang\":\"en\"}" >/dev/null
|
|
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_note_id/context" | j "print(any(\"an Mbin comment on PrivaPub\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \
|
|
&& ok "Mbin's comment threads under alice's note" || ko "Mbin's comment missing on PrivaPub"
|
|
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d "status=a PrivaPub comment on Mbin $run&in_reply_to_id=$books_thread_on_p&visibility=public"
|
|
until_true 45 '[ "$(mb GET "entry/$books_thread/comments" | j "print(any(\"a PrivaPub comment on Mbin\" in (c.get(\"body\") or \"\") for c in d.get(\"items\", [])))")" = "True" ]' \
|
|
&& ok "alice's reply becomes a comment on the Mbin thread" || ko "PrivaPub reply missing as an Mbin comment"
|
|
|
|
echo " votes"
|
|
p_counts() { curl -s -H "$PH" "$P/api/v1/statuses/$p_note_id" | j "print(d['favourites_count'], d['reblogs_count'], ((d.get('privapub') or {}).get('votes') or {}).get('down', 0))"; }
|
|
mb PUT "post/$p_note_on_mb/favourite" >/dev/null
|
|
until_true 45 '[ "$(p_counts | cut -d" " -f1)" = "1" ]' && ok "Mbin's favourite counts as a like on PrivaPub" || ko "Mbin's favourite not counted ($(p_counts))"
|
|
mb PUT "post/$p_note_on_mb/vote/1" >/dev/null
|
|
until_true 45 '[ "$(p_counts | cut -d" " -f2)" = "1" ]' && ok "Mbin's upvote arrives as a boost" || ko "Mbin's upvote not counted ($(p_counts))"
|
|
# (Mbin keeps downvotes to itself, and sends nothing when an upvote is taken back: its vote listener only announces an
|
|
# upvote, VoteHandleSubscriber::onVote)
|
|
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$books_thread_on_p/favourite"
|
|
until_true 45 '[ "$(mb GET "entry/$books_thread" | j "print(d[\"favourites\"])")" = "1" ]' \
|
|
&& ok "alice's like is a favourite on Mbin" || ko "alice's like not counted on Mbin ($(mb GET "entry/$books_thread" | j "print(d[\"favourites\"], d[\"uv\"])"))"
|
|
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$books_thread_on_p/reblog"
|
|
until_true 45 '[ "$(mb GET "entry/$books_thread" | j "print(d[\"uv\"])")" = "1" ]' \
|
|
&& ok "alice's boost is an upvote on Mbin" || ko "alice's boost not counted on Mbin ($(mb GET "entry/$books_thread" | j "print(d[\"favourites\"], d[\"uv\"])"))"
|
|
|
|
echo " private messages"
|
|
# Mbin takes a private message only as a ChatMessage, which PrivaPub sends it since NodeInfo names Mbin (owner decision
|
|
# 2026-10-05); its API never starts a conversation with an account elsewhere, so mbuser answers in the thread alice began
|
|
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d "status=@mbuser@mbin.test a secret from PrivaPub $run&visibility=direct"
|
|
until_true 45 '[ "$(mb_sql "select count(*) from message where body like '"'%a secret from PrivaPub $run%'"'")" = "1" ]' \
|
|
&& ok "alice's DM arrives as an Mbin private message" || ko "alice's DM never reached Mbin"
|
|
thread=$(mb_sql "select thread_id from message where body like '%a secret from PrivaPub $run%' limit 1")
|
|
mb POST "messages/thread/$thread/reply" "{\"body\":\"an Mbin answer $run\"}" >/dev/null
|
|
until_true 45 'curl -s -H "$PH" "$P/api/v1/conversations" | grep -q "an Mbin answer $run"' && ok "mbuser's answer arrives as a DM" || ko "Mbin's answer never reached alice"
|
|
|
|
echo " moderation"
|
|
p_locked() { curl -s -H "$PH" "$P/api/v1/statuses/$books_thread_on_p" | j "print((d.get('privapub') or {}).get('locked'))"; }
|
|
mb PUT "moderate/entry/$books_thread/lock" >/dev/null
|
|
until_true 45 '[ "$(p_locked)" = "True" ]' && ok "a moderator's lock reaches PrivaPub" || ko "a moderator's lock did not reach PrivaPub"
|
|
mb PUT "moderate/entry/$books_thread/lock" >/dev/null
|
|
until_true 45 '[ "$(p_locked)" = "False" ]' && ok "a moderator's unlock reaches PrivaPub" || ko "a moderator's unlock did not reach PrivaPub"
|
|
mb PUT "moderate/entry/$books_thread/trash" >/dev/null
|
|
until_true 45 '[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$PH" "$P/api/v1/statuses/$books_thread_on_p")" = "404" ]' \
|
|
&& ok "a moderator's removal reaches PrivaPub" || ko "a moderator's removal did not reach PrivaPub"
|
|
|
|
echo " unfollow"
|
|
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$books_on_p/unfollow"
|
|
until_true 45 '[ "$(mb_sql "select count(*) from magazine_subscription s join \"user\" u on u.id = s.user_id where u.ap_profile_id like '"'%alice_mbin%'"' and s.magazine_id = $books_on_mb")" = "0" ]' \
|
|
&& ok "alice's unfollow reaches the Mbin magazine" || ko "the Mbin magazine still counts alice"
|
|
|
|
echo " statistics"
|
|
stats_check mbin.test mbin
|