Files
thepraandClaude Opus 5.5 6b08da458a Pasture: NodeBB joins
NodeBB 4.16.1 runs in the pasture on the pasture's Mongo, set up by its
automated setup, with an API token written where NodeBB keeps them.
scenarios/nodebb.sh passes its 23 checks with no change to PrivaPub: a
category followed and its topic as a titled thread, replies both ways, a
follow of alice and her post there, votes both ways, an edit and a
deletion, a chat both ways, the unfollow and statistics.

NodeBB never federates a topic's lock, and its API follows an account
elsewhere only when named by its handle; both are in docs/INTEROP.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 16:58:08 +02:00

100 lines
7.9 KiB
Bash

# NodeBB 4.16.1: a forum whose categories are groups. alice follows a category; a topic there reaches her as a titled
# thread; replies both ways; nbuser follows alice and her public post becomes a topic there; votes both ways; an edit
# and a deletion; a chat both ways; the unfollow; statistics. NodeBB is driven through its API (/api/v3) as
# nbuser; a remote account's uid there is its actor's URL.
NB=https://nodebb.test:6443
NBT=$(cat "$here/.state/nodebb.token" 2>/dev/null)
# nb <method> <path> [json]: a NodeBB API call as nbuser, its `response`
nb() {
local method=$1 path=$2 body=${3:-}
if [ -n "$body" ]; then
site nodebb.test -s -X "$method" "$NB/api/v3/$path" -H "Authorization: Bearer $NBT" -H 'Content-Type: application/json' -d "$body"
else
site nodebb.test -s -X "$method" "$NB/api/v3/$path" -H "Authorization: Bearer $NBT"
fi
}
nb_get() { site nodebb.test -s "$NB/api/$1" -H "Authorization: Bearer $NBT"; }
enc() { python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$1"; }
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 "nodebb"
[ "$(nb GET users/1 | j "print(d['response']['username'])")" = "nbuser" ] && ok "NodeBB token for nbuser" || { ko "NodeBB token"; return 1; }
PT=$(privapub_token alice_nodebb)
PH="Authorization: Bearer $PT"
[ -n "$PT" ] && ok "PrivaPub token for alice_nodebb" || { ko "PrivaPub token for alice_nodebb"; return 1; }
alice_uri="$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d['url'])" | sed 's|/@|/peasants/|')"
run=$(date +%s)
echo " categories"
cat=$(nb POST categories "{\"name\":\"Pasture talk $run\",\"description\":\"talk of the pasture\"}" | j "print(json.dumps(d['response']))")
cid=$(echo "$cat" | j "print(d['cid'])"); handle=$(echo "$cat" | j "print(d['handle'])")
[ -n "$cid" ] && ok "nbuser opens a category" || ko "the category could not be made"
cat_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=$handle@nodebb.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ -n "$cat_on_p" ] && ok "PrivaPub resolves the category as a group" || ko "PrivaPub cannot resolve the category"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$cat_on_p/follow"
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$cat_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \
&& ok "alice follows the category (Accept arrived)" || ko "the category's Accept never arrived"
echo " topics"
topic=$(nb POST topics "{\"cid\":$cid,\"title\":\"A NodeBB topic $run\",\"content\":\"posted in a NodeBB category\"}" | j "print(json.dumps(d['response']))")
tid=$(echo "$topic" | j "print(d['tid'])"); main_pid=$(echo "$topic" | j "print(d['mainPid'])")
until_true 45 '[ -n "$(p_home_has "A NodeBB topic $run")" ]' && ok "the category brings its topic to alice's home" || ko "the topic never reached alice"
topic_on_p=$(p_home_has "A NodeBB topic $run")
[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$topic_on_p" | j "print((d.get('privapub') or {}).get('title'))")" = "A NodeBB topic $run" ] \
&& ok "as a thread with its title" || ko "the topic arrived without its title"
echo " replies"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d "status=a PrivaPub reply in the topic $run&in_reply_to_id=$topic_on_p&visibility=public"
nb_topic_posts() { nb_get "topic/$tid" | j "print(json.dumps([p for p in d.get('posts', [])]))"; }
until_true 45 '[ "$(nb_topic_posts | j "print(any(\"a PrivaPub reply in the topic\" in (p.get(\"content\") or \"\") for p in d))")" = "True" ]' \
&& ok "alice's reply joins the topic on NodeBB" || ko "alice's reply never reached the topic"
nb POST "topics/$tid" "{\"content\":\"a NodeBB reply to the topic $run\"}" >/dev/null
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$topic_on_p/context" | j "print(any(\"a NodeBB reply to the topic\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \
&& ok "nbuser's reply threads under the topic on PrivaPub" || ko "nbuser's reply missing on PrivaPub"
echo " following alice"
# (its API follows an account elsewhere only when named by its handle; a URL there makes a local follow of nobody)
nb PUT "users/$(enc alice_nodebb@privapub.test)/follow" >/dev/null
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d[\"followers_count\"])")" -ge 1 ]' \
&& ok "nbuser follows alice" || ko "NodeBB's follow never reached alice"
p_post=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=a PrivaPub post for NodeBB $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 '[ "$(nb GET "posts/$(enc "$p_post_uri")" | j "print(\"a PrivaPub post for NodeBB\" in (d[\"response\"].get(\"content\") or \"\"))")" = "True" ]' \
&& ok "alice's post reaches NodeBB" || ko "alice's post never reached NodeBB"
echo " votes"
nb PUT "posts/$(enc "$p_post_uri")/vote" '{"delta":1}' >/dev/null
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(d[\"favourites_count\"])")" = "1" ]' \
&& ok "nbuser's upvote counts as a like on PrivaPub" || ko "NodeBB's upvote not counted"
nb DELETE "posts/$(enc "$p_post_uri")/vote" >/dev/null
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id" | j "print(d[\"favourites_count\"])")" = "0" ]' \
&& ok "taking it back reaches PrivaPub" || ko "NodeBB's vote taken back still counts"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$topic_on_p/favourite"
until_true 45 '[ "$(nb GET "posts/$main_pid" | j "print(d[\"response\"].get(\"upvotes\"))")" = "1" ]' \
&& ok "alice's like is an upvote on NodeBB" || ko "alice's like not counted on NodeBB ($(nb GET "posts/$main_pid" | j "print(d[\"response\"].get(\"upvotes\"), d[\"response\"].get(\"votes\"))"))"
echo " edits, locks and deletes"
nb PUT "posts/$main_pid" "{\"content\":\"posted in a NodeBB category, then edited $run\",\"title\":\"A NodeBB topic $run\"}" >/dev/null
until_true 45 'curl -s -H "$PH" "$P/api/v1/statuses/$topic_on_p" | grep -q "then edited $run"' && ok "nbuser's edit reaches PrivaPub" || ko "the edit never reached PrivaPub"
# (NodeBB never federates a topic's lock: its activitypub/out.js has no Lock)
nb DELETE "topics/$tid/state" >/dev/null
until_true 45 '[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$PH" "$P/api/v1/statuses/$topic_on_p")" = "404" ]' \
&& ok "the deleted topic leaves PrivaPub" || ko "the deleted topic stays on PrivaPub"
echo " chats"
room=$(nb POST chats "{\"uids\":[\"$alice_uri\"]}" | j "print(d['response']['roomId'])")
nb POST "chats/$room" "{\"message\":\"a NodeBB chat $run\"}" >/dev/null
until_true 45 'curl -s -H "$PH" "$P/api/v1/conversations" | grep -q "a NodeBB chat $run"' && ok "nbuser's chat arrives as a DM" || ko "NodeBB's chat never reached alice"
dm=$(curl -s -H "$PH" "$P/api/v1/conversations" | j "print(next(c['last_status']['id'] for c in d if 'a NodeBB chat $run' in c['last_status']['content']))")
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d "status=@nbuser@nodebb.test a PrivaPub answer $run&in_reply_to_id=$dm&visibility=direct"
until_true 45 '[ "$(nb GET "chats/$room/messages" | j "print(any(\"a PrivaPub answer $run\" in (m.get(\"content\") or \"\") for m in d[\"response\"][\"messages\"]))")" = "True" ]' \
&& ok "alice's answer joins the chat on NodeBB" || ko "alice's answer missing from the chat"
echo " unfollow"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$cat_on_p/unfollow"
until_true 45 '[ "$(nb_get "category/$cid" | j "print(d.get(\"follower_count\", d.get(\"followerCount\", 0)))")" = "0" ]' \
&& ok "alice's unfollow reaches the category" || ko "the category still counts alice ($(nb_get "category/$cid" | j "print({k: v for k, v in d.items() if 'follow' in k.lower()})"))"
echo " statistics"
stats_check nodebb.test nodebb