From 6b08da458aa6ad292c27c1d4b24d4f46a1ef42de Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 16:58:08 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 7 +++ FEDERATION.md | 1 + docs/INTEROP.md | 7 +++ docs/ROADMAP.md | 2 +- tools/pasture/Caddyfile | 5 ++ tools/pasture/peers/nodebb.sh | 38 ++++++++++++ tools/pasture/scenarios/nodebb.sh | 99 +++++++++++++++++++++++++++++++ 7 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 tools/pasture/peers/nodebb.sh create mode 100644 tools/pasture/scenarios/nodebb.sh diff --git a/CLAUDE.md b/CLAUDE.md index 9277530..47e222e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -540,6 +540,13 @@ tools/pasture/run.sh down # removes e checks and one known gap (G-0008, direct messages): magazines both ways, threads with titles, a Note to a magazine as a microblog post, comments both ways, favourites and upvotes both ways, a moderator's lock, unlock and removal, the unfollow, statistics. +- **NodeBB (4.16.1):** the official image on the pasture's Mongo (database `nodebb`), set up once by its automated + setup (`SETUP` with `NODEBB_*` variables, admin nbuser) into the `pasture-nodebb-config` volume, trusting the CA + through `NODE_EXTRA_CA_CERTS`; its image runs `npm install` at every start. Its API (`/api/v3`) takes a bearer token + that `nodebb_settle` writes where NodeBB keeps its tokens. A remote account's uid there is its actor's URL, but its + API follows one only when named by its handle; a chat room is made, then written in. `scenarios/nodebb.sh`, 23 + checks: a category followed and its topic as a titled thread, replies both ways, nbuser following alice, votes both + ways, an edit and a deletion, a chat both ways, the unfollow, statistics. - **Hollo (0.9.19):** Fedify's microblog server on the shared Postgres, set up through its web form (which checks `Origin` against `Host`, so the request names `hollo.test` without the port). It needs a 44-character `SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario. diff --git a/FEDERATION.md b/FEDERATION.md index 44764bb..82f72bd 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -31,6 +31,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d - **Funkwhale 2.0.11** - **PieFed 1.7.17** - **Mbin 1.10.1** +- **NodeBB 4.16.1** - 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 224d4bf..8a214ba 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -620,6 +620,13 @@ What it showed: - `context` is a paged collection with an **ETag digest**; NodeBB refetches with `If-None-Match`. - Since 4.15, an Announce of anything but a Create or a plain object is accepted only from Group actors. - It sends `Move`/`Remove` of a whole context (FEP-f15d) and `Add{post → context}` (FEP-11dd). + - Chats are private Notes, threaded by `inReplyTo` into the room they answer. + - It never federates a topic's lock (its `activitypub/out.js` has no `Lock`), and its API follows an account + elsewhere only when named by its handle (`PUT /api/v3/users//follow`). + - **Pasture evidence (2026-10-05, NodeBB 4.16.1, `tools/pasture/scenarios/nodebb.sh`):** 23 checks pass, with no + change to PrivaPub: alice follows a category and its topic reaches her as a titled thread; replies both ways; + nbuser follows alice and her post reaches NodeBB; nbuser's upvote counts as a like and taking it back too, alice's + like is an upvote there; an edit and a deletion; a chat both ways; the unfollow; statistics. - **Discourse** (plugin, semi-dormant): categories and tags are Groups. "Full Topic" mode makes the topic an OrderedCollection used as `context`. - **Friendica** (2026.05-1): diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6ecc337..20c4bc6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -69,7 +69,7 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati Funkwhale serve it; Funkwhale's answers named after our follow and its deletions of several uploads. - GoToSocial's interaction policies both ways; personas join and leave remote events; third-party replies to a persona's posts passed on to its followers (owner decisions 2026-10-05). - - wave 2, under way: PieFed and Mbin in the pasture with scenarios (2026-10-05). What they showed and was fixed: the + - wave 2, under way: PieFed, Mbin and NodeBB in the pasture with scenarios (2026-10-05). What they showed and was fixed: the instance actor answers at the server's root, where PieFed looks for the inbox it announces to; a community's removal of a post on its own server is believed at once; a followed group's post its own server sends without announcing it is kept, and a bare `Lock` from the post's server is taken (Mbin). Open: direct messages to Mbin, which takes diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index aff603b..192a983 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -102,3 +102,8 @@ mbin.test { tls internal reverse_proxy pasture-mbin:80 } + +nodebb.test { + tls internal + reverse_proxy pasture-nodebb:4567 +} diff --git a/tools/pasture/peers/nodebb.sh b/tools/pasture/peers/nodebb.sh new file mode 100644 index 0000000..4dea3c8 --- /dev/null +++ b/tools/pasture/peers/nodebb.sh @@ -0,0 +1,38 @@ +# NodeBB 4.16.1: forum software whose categories are ActivityPub groups and whose topics are threads; federation is on +# by default since 4.0. The official image on the pasture's Mongo (database nodebb), set up once by its automated setup +# (SETUP with NODEBB_* variables: admin nbuser), then started on that config, trusting the pasture's CA through +# NODE_EXTRA_CA_CERTS. Its image runs `npm install` at every start. Its API (/api/v3) takes a bearer token, which +# nodebb_settle writes into its database as NodeBB's own token list does (token:, tokens:createtime, tokens:uid). +NODEBB_IMAGE=${NODEBB_IMAGE:-ghcr.io/nodebb/nodebb:4.16.1} +NODEBB_PASSWORD=NodeBB-Pasture-Pass-1 + +nodebb_up() { + podman volume exists pasture-nodebb-config || podman volume create --label pasture=1 pasture-nodebb-config >/dev/null + podman volume exists pasture-nodebb-uploads || podman volume create --label pasture=1 pasture-nodebb-uploads >/dev/null + local common=(--network $net --label pasture=1 -v pasture-nodebb-config:/opt/config:U -v pasture-nodebb-uploads:/usr/src/app/public/uploads:U + -v "$ca/bundle.pem:/ca/bundle.pem:z,ro" -e NODE_EXTRA_CA_CERTS=/ca/bundle.pem) + if ! podman run --rm "${common[@]}" --entrypoint test "$NODEBB_IMAGE" -s /opt/config/config.json; then + podman run --rm "${common[@]}" -e SETUP=true -e NODEBB_URL=https://nodebb.test -e NODEBB_PORT=4567 \ + -e NODEBB_ADMIN_USERNAME=nbuser -e NODEBB_ADMIN_PASSWORD="$NODEBB_PASSWORD" -e NODEBB_ADMIN_EMAIL=nbuser@nodebb.test \ + -e NODEBB_DB=mongo -e NODEBB_DB_HOST=mongo -e NODEBB_DB_PORT=27017 -e NODEBB_DB_NAME=nodebb "$NODEBB_IMAGE" \ + > "$here/.state/nodebb-setup.log" 2>&1 + fi + podman run -d --replace --name pasture-nodebb "${common[@]}" "$NODEBB_IMAGE" >/dev/null + for _ in $(seq 1 150); do + site nodebb.test -s -o /dev/null -w '%{http_code}' https://nodebb.test:6443/api/config 2>/dev/null | grep -q 200 && break + sleep 2 + done + nodebb_settle + echo "nodebb: https://nodebb.test:6443" +} + +# nbuser's API token, written where NodeBB keeps them +nodebb_settle() { + local token="pasture-nbuser-token" + podman exec pasture-mongo mongosh --quiet nodebb --eval " + const now = Date.now(); + db.objects.updateOne({_key: 'token:$token'}, {\$set: {uid: 1, description: 'pasture', timestamp: now}}, {upsert: true}); + db.objects.updateOne({_key: 'tokens:createtime', value: '$token'}, {\$set: {score: now}}, {upsert: true}); + db.objects.updateOne({_key: 'tokens:uid', value: '$token'}, {\$set: {score: 1}}, {upsert: true});" >/dev/null + echo "$token" > "$here/.state/nodebb.token" +} diff --git a/tools/pasture/scenarios/nodebb.sh b/tools/pasture/scenarios/nodebb.sh new file mode 100644 index 0000000..07a1731 --- /dev/null +++ b/tools/pasture/scenarios/nodebb.sh @@ -0,0 +1,99 @@ +# 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 [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