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:
1 parent
26346cde30
commit
6b08da458a
7 files changed
+158
-1
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user