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
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 16:58:08 +02:00
1 parent 26346cde30
commit 6b08da458a
7 files changed
+158 -1

No files matched your search

+7
View File
@@ -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 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 as a microblog post, comments both ways, favourites and upvotes both ways, a moderator's lock, unlock and removal, the
unfollow, statistics. 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 - **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 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. `SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario.
+1
View File
@@ -31,6 +31,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d
- **Funkwhale 2.0.11** - **Funkwhale 2.0.11**
- **PieFed 1.7.17** - **PieFed 1.7.17**
- **Mbin 1.10.1** - **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**, - 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** **Pleroma 2.10.2**
+7
View File
@@ -620,6 +620,13 @@ What it showed:
- `context` is a paged collection with an **ETag digest**; NodeBB refetches with `If-None-Match`. - `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. - 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). - 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/<user@host>/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 - **Discourse** (plugin, semi-dormant): categories and tags are Groups. "Full Topic" mode makes the topic an
OrderedCollection used as `context`. OrderedCollection used as `context`.
- **Friendica** (2026.05-1): - **Friendica** (2026.05-1):
+1 -1
View File
@@ -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. 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 - 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). 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 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 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 it is kept, and a bare `Lock` from the post's server is taken (Mbin). Open: direct messages to Mbin, which takes
+5
View File
@@ -102,3 +102,8 @@ mbin.test {
tls internal tls internal
reverse_proxy pasture-mbin:80 reverse_proxy pasture-mbin:80
} }
nodebb.test {
tls internal
reverse_proxy pasture-nodebb:4567
}
+38
View File
@@ -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:<t>, 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"
}
+99
View File
@@ -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 <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