Mbin joins the pasture; a magazine's own threads and locks are taken

Mbin 1.10.1 runs in the pasture (its image, a messenger worker, a RabbitMQ
of its own, its API limits raised), and peers/mbin_token.py gets mbuser's
token through the authorization-code flow. scenarios/mbin.sh: 24 checks and
one known gap, magazines both ways, titled threads, a Note to a magazine as
a microblog post, comments, favourites and upvotes both ways, a moderator's
lock, unlock and removal, the unfollow and statistics.

What it showed:
- Mbin sends a magazine's threads to its subscribers as the author's Create,
  the magazine as its audience, never announced. A post whose group is
  followed here and lives on the post's own server is now kept as if
  announced; the same from another server is not.
- A moderator's lock is a bare Lock (and Undo{Lock}): LockHandler takes it
  from the post's own server only.
- Mbin takes private messages only as ChatMessage and its actors say
  nothing about it; PrivaPub never decides by a server's software, so this
  stays open as G-0008 for the owner.

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-05 16:41:50 +02:00
1 parent 47d6e22988
commit 26346cde30
15 files changed
+493 -8

No files matched your search

+5
View File
@@ -97,3 +97,8 @@ piefed.test {
tls internal
reverse_proxy pasture-piefed:5000
}
mbin.test {
tls internal
reverse_proxy pasture-mbin:80
}
+104
View File
@@ -0,0 +1,104 @@
# Mbin 1.10.1: the threadiverse in Symfony (magazines, threads, comments, microblog posts, votes up and down), from its
# own image: FrankenPHP serving plain HTTP behind Caddy, and a messenger worker for its queues, on the shared Postgres
# (database mbin) and Redis (db 12), with a RabbitMQ of its own (its transports carry AMQP options). Symfony's HTTP
# client trusts the system bundle, so the pasture's is mounted over it. Its admin is mbuser, made by its console; its
# API takes an OAuth2 token, which mbin_settle gets through the authorization-code flow as mbuser (a client-credentials
# client acts as a bot, which may not vote).
MBIN_IMAGE=${MBIN_IMAGE:-ghcr.io/mbinorg/mbin:v1.10.1}
MBIN_RABBITMQ_IMAGE=${MBIN_RABBITMQ_IMAGE:-docker.io/library/rabbitmq:4-alpine}
MBIN_PASSWORD=Mbin-Pasture-Pass-1
. "$here/peers/shared.sh"
mbin_env() {
local dir="$here/.state/mbin"
[ -s "$dir/secret" ] || head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$dir/secret"
[ -s "$dir/mercure" ] || head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$dir/mercure"
[ -s "$dir/oauth-key" ] || head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$dir/oauth-key"
cat <<ENV
APP_ENV=prod
APP_SECRET=$(cat "$dir/secret")
MBIN_USER=root
SERVER_NAME=:80
KBIN_DOMAIN=mbin.test
KBIN_TITLE=Pasture Mbin
KBIN_DEFAULT_LANG=en
KBIN_FEDERATION_ENABLED=true
KBIN_CONTACT_EMAIL=contact@mbin.test
KBIN_SENDER_EMAIL=noreply@mbin.test
KBIN_JS_ENABLED=true
KBIN_REGISTRATIONS_ENABLED=true
KBIN_API_ITEMS_PER_PAGE=25
KBIN_STORAGE_URL=https://mbin.test/media
KBIN_CAPTCHA_ENABLED=false
KBIN_ADMIN_ONLY_OAUTH_CLIENTS=false
MBIN_DOWNVOTES_MODE=enabled
MBIN_NEW_USERS_NEED_APPROVAL=false
MBIN_USE_FEDERATION_ALLOW_LIST=false
DATABASE_URL=postgresql://pasture:pasture@postgres:5432/mbin?serverVersion=17&charset=utf8
REDIS_DNS=redis://redis:6379/12
MESSENGER_TRANSPORT_DSN=amqp://guest:guest@pasture-mbin-rabbitmq:5672/%2f/messages
MAILER_DSN=null://null
MERCURE_URL=http://localhost/.well-known/mercure
MERCURE_PUBLIC_URL=https://mbin.test/.well-known/mercure
MERCURE_JWT_SECRET=$(cat "$dir/mercure")
MERCURE_PUBLISHER_JWT_KEY=$(cat "$dir/mercure")
MERCURE_SUBSCRIBER_JWT_KEY=$(cat "$dir/mercure")
CORS_ALLOW_ORIGIN=^https?://mbin\.test$
LOCK_DSN=flock
TRUSTED_PROXIES=$subnet
OAUTH_PRIVATE_KEY=/oauth2/private.pem
OAUTH_PUBLIC_KEY=/oauth2/public.pem
OAUTH_PASSPHRASE=$MBIN_PASSWORD
OAUTH_ENCRYPTION_KEY=$(cat "$dir/oauth-key")
ENV
}
mbin_up() {
shared_postgres_up
shared_redis_up
pg_db mbin
mkdir -p "$here/.state/mbin/oauth2"
if [ ! -s "$here/.state/mbin/oauth2/public.pem" ]; then
openssl genrsa -aes256 -passout "pass:$MBIN_PASSWORD" -out "$here/.state/mbin/oauth2/private.pem" 4096 2>/dev/null
openssl rsa -in "$here/.state/mbin/oauth2/private.pem" -passin "pass:$MBIN_PASSWORD" -pubout -out "$here/.state/mbin/oauth2/public.pem" 2>/dev/null
chmod 644 "$here/.state/mbin/oauth2/"*.pem
fi
mbin_env > "$here/.state/mbin/env"
# its API's own limits (two threads every six minutes) would throttle a scripted run: the same file, every limit raised
podman run --rm --entrypoint cat "$MBIN_IMAGE" config/packages/rate_limiter.yaml \
| sed -E 's/^( +limit:) [0-9]+$/\1 100000/' > "$here/.state/mbin/rate_limiter.yaml"
# (as its own user on a volume it owns: started as root, it writes an .erlang.cookie it then cannot read)
podman volume exists pasture-mbin-rabbitmq || podman volume create --label pasture=1 pasture-mbin-rabbitmq >/dev/null
podman run -d --replace --name pasture-mbin-rabbitmq --network $net --label pasture=1 --user rabbitmq -v pasture-mbin-rabbitmq:/var/lib/rabbitmq:U \
"$MBIN_RABBITMQ_IMAGE" >/dev/null
for _ in $(seq 1 60); do podman exec pasture-mbin-rabbitmq rabbitmq-diagnostics -q ping >/dev/null 2>&1 && break; sleep 2; done
podman volume exists pasture-mbin-media || podman volume create --label pasture=1 pasture-mbin-media >/dev/null
local common=(--network $net --label pasture=1 --env-file "$here/.state/mbin/env" -v pasture-mbin-media:/app/public/media
-v "$here/.state/mbin/oauth2:/oauth2:z,ro" -v "$ca/bundle.pem:/etc/ssl/certs/ca-certificates.crt:z,ro"
-v "$here/.state/mbin/rate_limiter.yaml:/app/config/packages/rate_limiter.yaml:z,ro")
podman run -d --replace --name pasture-mbin "${common[@]}" "$MBIN_IMAGE" >/dev/null
# the web container runs the migrations as it starts; the worker waits for them
for _ in $(seq 1 120); do
podman logs pasture-mbin 2>&1 | grep -q "PHP app ready" && break
sleep 2
done
podman run -d --replace --name pasture-mbin-worker "${common[@]}" "$MBIN_IMAGE" \
php bin/console messenger:consume scheduler_default old async outbox deliver inbox resolve receive failed --time-limit=86400 >/dev/null
for _ in $(seq 1 60); do
site mbin.test -s -o /dev/null -w '%{http_code}' https://mbin.test:6443/api/instance 2>/dev/null | grep -q 200 && break
sleep 2
done
mbin_settle
echo "mbin: https://mbin.test:6443"
}
mbin_console() { podman exec pasture-mbin php bin/console "$@"; }
# mbuser (admin, verified), the instance's keys, and mbuser's OAuth token from the authorization-code flow
mbin_settle() {
mbin_console mbin:ap:keys:update >/dev/null 2>&1 || true
mbin_console mbin:user:create mbuser mbuser@mbin.test "$MBIN_PASSWORD" >/dev/null 2>&1 || true
mbin_console mbin:user:admin mbuser >/dev/null 2>&1 || true
mbin_console mbin:user:verify mbuser >/dev/null 2>&1 || true
python3 "$here/peers/mbin_token.py" "$MBIN_PASSWORD" "$here/.state/mbin/client.json" > "$here/.state/mbin.token" 2>"$here/.state/mbin/token.log" || true
}
+101
View File
@@ -0,0 +1,101 @@
"""mbuser's OAuth2 access token on the pasture's Mbin, through the authorization-code flow a person would follow: an
OAuth client made through the API, mbuser signed in through the login form, consent given, the code exchanged.
Prints the token. Usage: mbin_token.py <password> <file keeping the client>"""
import http.client
import json
import os
import re
import socket
import ssl
import sys
import urllib.parse
HOST = "mbin.test"
CA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".ca", "root.crt")
REDIRECT = "https://pasture.invalid/callback"
SCOPES = "read write delete subscribe block vote report user moderate"
cookies = {}
def request(method, path, body=None, headers=None):
"""One request to Mbin through Caddy on 127.0.0.1:6443, named mbin.test (SNI and Host); the CA is the pasture's own"""
context = ssl.create_default_context(cafile=CA)
conn = http.client.HTTPSConnection(HOST, 6443, context=context, timeout=60)
conn.sock = context.wrap_socket(socket.create_connection(("127.0.0.1", 6443), timeout=60), server_hostname=HOST)
# (Mbin names what it makes after the request's host: mbin.test, without the workstation's port)
headers = dict(headers or {}, Host=HOST)
if cookies:
headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in cookies.items())
conn.request(method, path, body=body, headers=headers)
response = conn.getresponse()
data = response.read().decode("utf-8", "replace")
for name, value in response.getheaders():
if name.lower() == "set-cookie":
key, _, rest = value.partition("=")
cookies[key.strip()] = rest.split(";")[0]
return response.status, dict((k.lower(), v) for k, v in response.getheaders()), data
def follow(path):
"""GETs path and follows redirects on Mbin, returning the last answer and where a redirect away from Mbin pointed"""
for _ in range(10):
status, headers, data = request("GET", path)
location = headers.get("location")
if status not in (301, 302, 303, 307, 308) or not location:
return status, data, None
if location.startswith(REDIRECT):
return status, data, location
parsed = urllib.parse.urlsplit(location)
path = parsed.path + ("?" + parsed.query if parsed.query else "")
raise SystemExit("too many redirects")
def csrf(html):
match = re.search(r'name="_csrf_token"\s+value="([^"]+)"', html)
if not match:
raise SystemExit("no CSRF token in the page")
return match.group(1)
def main(password, saved):
# one client for every run (Mbin limits how many are made)
client = json.load(open(saved)) if os.path.exists(saved) else None
if client is None:
status, _, data = request("POST", "/api/client", json.dumps({
"name": "pasture", "contactEmail": "pasture@mbin.test", "description": "the pasture's scenarios", "public": False,
"redirectUris": [REDIRECT], "grants": ["authorization_code", "refresh_token"], "scopes": SCOPES.split()
}), {"Content-Type": "application/json"})
if status >= 300:
raise SystemExit(f"client: {status} {data[:300]}")
client = json.loads(data)
json.dump({"identifier": client["identifier"], "secret": client["secret"]}, open(saved, "w"))
status, _, page = request("GET", "/login")
form = urllib.parse.urlencode({"email": "mbuser", "password": password, "_csrf_token": csrf(page)})
status, headers, _ = request("POST", "/login", form, {"Content-Type": "application/x-www-form-urlencoded"})
if status not in (302, 303) or "/login" in headers.get("location", ""):
raise SystemExit(f"login refused: {status} {headers.get('location')}")
query = urllib.parse.urlencode({"response_type": "code", "client_id": client["identifier"], "redirect_uri": REDIRECT,
"scope": SCOPES, "state": "pasture"})
status, page, done = follow("/authorize?" + query)
if done is None:
consent = "/consent?" + query
form = urllib.parse.urlencode({"consent": "yes", "_csrf_token": csrf(page)})
status, headers, _ = request("POST", consent, form, {"Content-Type": "application/x-www-form-urlencoded"})
location = headers.get("location", "")
parsed = urllib.parse.urlsplit(location)
status, page, done = follow(parsed.path + "?" + parsed.query)
if done is None:
raise SystemExit(f"no code: {status} {page[:300]}")
code = urllib.parse.parse_qs(urllib.parse.urlsplit(done).query)["code"][0]
form = urllib.parse.urlencode({"grant_type": "authorization_code", "client_id": client["identifier"],
"client_secret": client["secret"], "redirect_uri": REDIRECT, "code": code})
status, _, data = request("POST", "/token", form, {"Content-Type": "application/x-www-form-urlencoded"})
if status >= 300:
raise SystemExit(f"token: {status} {data[:300]}")
print(json.loads(data)["access_token"])
main(sys.argv[1], sys.argv[2])
+113
View File
@@ -0,0 +1,113 @@
# 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), a direct message (G-0008), 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"
[ -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, and drops alice's direct Note ("PM: not implemented", G-0008); its
# API never starts a conversation with an account elsewhere, so nothing comes the other way through it
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 30 '[ "$(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" || xf "alice's DM never reaches Mbin, which takes private messages only as ChatMessage (G-0008)"
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
+14
View File
@@ -107,5 +107,19 @@
"opened": "2026-10-05",
"status": "open",
"note": "A delivered DM is handled as one. Resolving its address (a recipient pasting its link; the town's driver does it to reply) makes Pixelfed's instance actor fetch it, which PrivaPub allows since a recipient lives there, and the copy is stored with scope private."
},
{
"id": "G-0008",
"title": "A direct message to an Mbin account never arrives: Mbin takes a private message only as a ChatMessage",
"match": {
"feature": "deliver\\.direct",
"observer": "mbin"
},
"kind": "server",
"phase": "P3",
"code": "mbin 1.10.1 src/Service/ActivityPub/ActivityPubContent.php getVisibility(): a Note neither public nor to the author's followers throws 'PM: not implemented'; only CreateHandler's ChatMessage branch makes a private message",
"opened": "2026-10-05",
"status": "open",
"note": "Mbin's actors advertise nothing that says so, and PrivaPub never decides by a server's software name, so choosing ChatMessage for a recipient waits for the owner. Mbin's API also never starts a conversation with an account elsewhere, so nothing comes the other way through it."
}
]