diff --git a/CLAUDE.md b/CLAUDE.md index 4fcf844..ceb1bb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -607,6 +607,12 @@ tools/pasture/run.sh down # removes e announced by the other), that alice's public post goes to the relays (and through both to Mastodon: Activity-Relay's forward on its FEP-8b32 proof, after the scenario makes Mastodon read alice's keys anew) and nothing less public, and has Mastodon leave again, so the town sees no relayed posts. 16 checks. +- **Forte (26.9.10, `peers/forte.sh`):** built from its tag by `images/forte` (composer on Hubzilla's PHP image, an nginx + routing through `index.php?req=`), on the shared MySQL (database `forte`), its `.htconfig.php` written to + `.state/forte`, a cron sidecar (`src/Daemon/Run.php Cron`). The pasture bundle is mounted over `library/cacert.pem`, + the bundle its curl uses. ftuser (administrator) and ftfriend are made through its PHP (`forte_eval`: `Account::create`, + `Channel::create` with the role `social` and `autoperms`, or every connection waits). Connecting from the command line + runs the notifier itself (`ft_connect`). `scenarios/forte.sh`. - **Hubzilla (11.4.1, `peers/hubzilla.sh`):** the hlhd image (php-fpm and nginx on 8080) on the shared MySQL (database `hubzilla`, its schema loaded from the image), `.htconfig.php` written to `.state/hubzilla` and mounted, a cron sidecar running `Zotlabs/Daemon/Master.php Cron` each minute. Its addons `pubcrawl` (ActivityPub) and `statistics` diff --git a/FEDERATION.md b/FEDERATION.md index b68f510..ce95084 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -45,6 +45,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d - **BookWyrm 0.9.3** - **Vernissage 1.43.0** - **Hubzilla 11.4.1** with its pubcrawl addon +- **Forte 26.9.10** (portable identities: actors served through `/.well-known/apgateway/did:key:…`) - **Activity-Relay 2.0.9** and **aode-relay 0.3.129**, as relays PrivaPub reads from - 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 b540598..ed11025 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -854,6 +854,26 @@ without a port, before it gives out its OAuth client. never as the relay's boost), and nobody's home; a persona's public post goes to the relays, nothing less public, and reaches Mastodon through both (forwarded on its proof, and announced). +### Forte 26.9.10 + +- **Portable identities (FEP-ef61):** a channel's actor is `https:///.well-known/apgateway/did:key:z6Mk…/actor`, + its keys a `did:key` and Multikeys; WebFinger lists `ap://did:key…` and the gateway address beside the usual ones. + PrivaPub keeps it as an ordinary actor at that https address. +- **Follows a profile page:** its `Follow` names the persona's profile URL (`/@name`, WebFinger's alias) rather than the + actor's id; PrivaPub takes either (a 404 before). +- **Everything in a thread is added to its conversation** (FEP-171b): its edits come only as `Add{Update}`, under the + same activity id as the post's `Create`, and likes as `Add{Like}`. PrivaPub unwraps Create, Update and Delete, telling + an Update from the Create whose id it reuses by what it carries. +- **Its own collections name themselves wrongly:** a channel's followers, following and outbox give an `id` with the + gateway path twice and a trailing `?`, which 404s, so their counts are not read. +- **Running it:** no image is published; `images/forte` builds its tag with composer on Hubzilla's PHP image, behind an + nginx that routes through `index.php?req=`. Its curl uses `library/cacert.pem`, not the system store. The `social` + role leaves each connection pending; the pasture's channels set `autoperms`. Background jobs it starts from the + command line do not outlive `podman exec`, so the scenario runs the Follow's notifier itself. Likes take `verb=Like`. +- **Pasture evidence (2026-10-06, `tools/pasture/scenarios/forte.sh`):** follows both ways, posts both ways, comments + both ways, a third channel's comment passed on by the thread's owner, likes both ways, edits and deletions both ways, + the unfollow, statistics. + ### Hubzilla 11.4.1 (pubcrawl) - **Channels speak ActivityPub only with the "Activitypub Protocol" app** installed for them (the pubcrawl addon on diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index 3e730fa..e3b7baf 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -108,6 +108,11 @@ nodebb.test { reverse_proxy pasture-nodebb:4567 } +forte.test { + tls internal + reverse_proxy pasture-forte:8080 +} + hubzilla.test { tls internal reverse_proxy pasture-hubzilla:8080 diff --git a/tools/pasture/images/forte/Containerfile b/tools/pasture/images/forte/Containerfile new file mode 100644 index 0000000..c655cd4 --- /dev/null +++ b/tools/pasture/images/forte/Containerfile @@ -0,0 +1,14 @@ +# Forte 26.9.10 (the ActivityPub-only fork of Streams) from its tag: the project publishes no image. Its PHP dependencies +# come from composer; Hubzilla's image (PHP 8.3 with every extension Forte asks for, php-fpm and nginx on 8080) carries +# it, Forte's code in place of Hubzilla's and an nginx that routes through `index.php?req=`, as Forte's own sample does +FROM docker.io/library/composer:2 AS builder +RUN git clone --depth 1 --branch v26.9.10 https://codeberg.org/fortified/forte /build \ + && cd /build && composer install --no-dev --no-interaction --no-progress --ignore-platform-reqs --optimize-autoloader + +FROM docker.io/hlhd/hubzilla:v11.4.1 +USER root +RUN rm -rf /var/www/html && mkdir -p /var/www/html +COPY --from=builder --chown=www-data:www-data /build /var/www/html +COPY nginx.conf /etc/nginx/nginx.conf +RUN mkdir -p "/var/www/html/store/[data]/smarty3" /var/www/html/cache/smarty3 && chown -R www-data:www-data /var/www/html/store /var/www/html/cache +USER www-data diff --git a/tools/pasture/images/forte/nginx.conf b/tools/pasture/images/forte/nginx.conf new file mode 100644 index 0000000..67b2cd1 --- /dev/null +++ b/tools/pasture/images/forte/nginx.conf @@ -0,0 +1,69 @@ +# Forte behind Caddy: php-fpm on 127.0.0.1:9000, everything routed through index.php?req= (Forte's sample), dotfiles +# and its configuration never served +worker_processes auto; +pid /tmp/nginx.pid; +error_log /dev/stderr warn; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /dev/stdout; + client_body_temp_path /tmp/nginx-client-body; + proxy_temp_path /tmp/nginx-proxy; + fastcgi_temp_path /tmp/nginx-fastcgi; + uwsgi_temp_path /tmp/nginx-uwsgi; + scgi_temp_path /tmp/nginx-scgi; + sendfile on; + server_tokens off; + absolute_redirect off; + + set_real_ip_from 10.0.0.0/8; + set_real_ip_from 11.0.0.0/8; + set_real_ip_from 172.16.0.0/12; + set_real_ip_from 192.168.0.0/16; + real_ip_header X-Forwarded-For; + + map $http_x_forwarded_proto $fcgi_https { + default on; + http ""; + https on; + } + + server { + listen 8080; + server_name _; + root /var/www/html; + index index.php; + client_max_body_size 100m; + + location ~* ^/(\.htconfig\.php|\.env|\.git|composer\.|compose\.) { + return 404; + } + location ~ ^/(store|util|contrib|tests)(/|$) { + return 404; + } + location ~ ^/view/tpl/.*\.tpl$ { + return 404; + } + location ^~ /.well-known/ { + try_files $uri $uri/ /index.php?req=$uri&$args; + } + location ~ \.php$ { + try_files $uri =404; + fastcgi_split_path_info ^(.+\.php)(/.*)$; + fastcgi_pass 127.0.0.1:9000; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $request_filename; + fastcgi_param HTTPS $fcgi_https; + fastcgi_param REMOTE_USER $http_authorization; + fastcgi_read_timeout 120; + } + location / { + try_files $uri $uri/ /index.php?req=$uri&$args; + } + } +} diff --git a/tools/pasture/peers/forte.sh b/tools/pasture/peers/forte.sh new file mode 100644 index 0000000..486e54f --- /dev/null +++ b/tools/pasture/peers/forte.sh @@ -0,0 +1,103 @@ +# Forte 26.9.10: the ActivityPub-only fork of Streams (Hubzilla's lineage), with conversation containers (FEP-171b), +# integrity proofs (FEP-8b32) and portable identities. Built from its tag (images/forte: composer's dependencies on +# Hubzilla's PHP image) and run on the shared MySQL (database forte, its schema from install/schema_mysql.sql), its +# configuration written here and mounted as .htconfig.php, its store in a volume, a cron sidecar. It trusts Caddy's CA +# through the bundle mounted as its system store and as library/cacert.pem, the bundle its curl is given instead. Accounts and channels are made through its own PHP; its API +# (api/z/1.0) takes HTTP Basic authentication by the account's email and password. +FORTE_IMAGE=${FORTE_IMAGE:-localhost/pasture-forte:26.9.10} +FORTE_PASSWORD=Forte-Pasture-1 +. "$here/peers/shared.sh" + +forte_php() { podman exec -u www-data -w /var/www/html pasture-forte php "$@"; } +# forte_eval : PHP run inside Forte, its CLI start done +forte_eval() { podman exec -i -u www-data -w /var/www/html pasture-forte php -r "require_once('vendor/autoload.php'); require_once('include/cli_startup.php'); cli_startup(); $1"; } + +forte_config() { + cat < "$here/.state/forte/htconfig.php" + podman image exists "$FORTE_IMAGE" || podman build -q -t "$FORTE_IMAGE" "$here/images/forte" >/dev/null + podman volume exists pasture-forte-store || podman volume create --label pasture=1 pasture-forte-store >/dev/null + podman run -d --replace --name pasture-forte --network $net --label pasture=1 \ + -v "$here/.state/forte/htconfig.php:/var/www/html/.htconfig.php:z,ro" -v pasture-forte-store:/var/www/html/store \ + -v "$ca/bundle.pem:/etc/ssl/certs/ca-certificates.crt:z,ro" -v "$ca/bundle.pem:/var/www/html/library/cacert.pem:z,ro" "$FORTE_IMAGE" >/dev/null + podman exec -u root pasture-forte sh -c 'mkdir -p "/var/www/html/store/[data]/smarty3" /var/www/html/cache/smarty3 \ + && chown -R www-data /var/www/html/store /var/www/html/cache' + if [ "$(podman exec pasture-mysql mysql -upasture -ppasture -N forte -e "show tables like 'account'" 2>/dev/null)" != "account" ]; then + podman exec pasture-forte cat /var/www/html/install/schema_mysql.sql | podman exec -i pasture-mysql mysql -upasture -ppasture forte 2>/dev/null + fi + for _ in $(seq 1 60); do + site forte.test -s -o /dev/null -w '%{http_code}' https://forte.test:6443/.well-known/nodeinfo 2>/dev/null | grep -q 200 && break + sleep 2 + done + podman run -d --replace --name pasture-forte-cron --network $net --label pasture=1 --volumes-from pasture-forte \ + -u www-data -w /var/www/html --entrypoint sh "$FORTE_IMAGE" -c 'while true; do php src/Daemon/Run.php Cron; sleep 60; done' >/dev/null + forte_user ftuser + forte_user ftfriend + echo "forte: https://forte.test:6443" +} + +# forte_user : an account (@forte.test, the pasture's password; the first is the server's administrator, +# as admin_email names it) with a public channel of that name that takes connections without asking (autoperms, as its +# roles that approve by themselves set it: "social" leaves each one pending) +forte_user() { + forte_eval " +use Code\\Lib\\Account; use Code\\Lib\\Channel; +\$email = '$1@forte.test'; +\$r = q(\"select account_id from account where account_email = '%s'\", dbesc(\$email)); +if (! \$r) { + \$a = Account::create(['email' => \$email, 'password' => '$FORTE_PASSWORD', 'password2' => '$FORTE_PASSWORD']); + if (empty(\$a['success'])) { echo 'account: ' . \$a['message'] . PHP_EOL; exit(1); } + \$account_id = \$a['account']['account_id']; +} else + \$account_id = \$r[0]['account_id']; +q('update account set account_flags = 0 where account_id = %d', intval(\$account_id)); +\$c = Channel::from_username('$1'); +if (! \$c) { + \$x = Channel::create(['account_id' => \$account_id, 'nickname' => '$1', 'name' => '$1', 'permissions_role' => 'social', 'publish' => 1]); + if (empty(\$x['success'])) { echo 'channel: ' . \$x['message'] . PHP_EOL; exit(1); } + \$c = Channel::from_username('$1'); +} +set_pconfig(\$c['channel_id'], 'system', 'autoperms', 1); +echo 'ok ' . \$c['channel_id'] . PHP_EOL; +" +} + +# forte_web : a request to Forte's web pages as , signed in through its login form (liking, +# dropping) +forte_web() { + local nick=$1 jar="$here/.state/forte/$1.cookies"; shift + if ! site forte.test -s -b "$jar" https://forte.test:6443/stream 2>/dev/null | grep -qi logout; then + rm -f "$jar" + site forte.test -s -o /dev/null -c "$jar" -X POST https://forte.test:6443/login -d auth-params=login \ + -d "username=$nick@forte.test" --data-urlencode "password=$FORTE_PASSWORD" + fi + site forte.test -s -b "$jar" -c "$jar" "$@" +} diff --git a/tools/pasture/scenarios/forte.sh b/tools/pasture/scenarios/forte.sh new file mode 100644 index 0000000..5cfd386 --- /dev/null +++ b/tools/pasture/scenarios/forte.sh @@ -0,0 +1,99 @@ +# Forte 26.9.10 (the ActivityPub-only fork of Streams): follows both ways; posts both ways; comments both ways; likes both +# ways; a comment by ftfriend in ftuser's thread, which Forte passes on to alice as the thread's owner; edits and +# deletions both ways; the unfollow; statistics. Forte's API (api/z/1.0) posts, edits and reads; what it has no route +# for is done through its own PHP (connecting) or its web pages signed in (liking, dropping; peers/forte.sh), and +# what it holds is read from its MySQL (database forte, the item table by mid). +. "$here/peers/forte.sh" +FT=https://forte.test:6443 +# ftc : Forte's API as +ftc() { local nick=$1; shift; site forte.test -s -u "$nick@forte.test:$FORTE_PASSWORD" "$@"; } +ft_sql() { podman exec pasture-mysql mysql -upasture -ppasture forte -Nse "$1" 2>/dev/null; } +# ft_item [nick]: the id of the item Forte holds under that ActivityPub id in a channel (ftuser's) +ft_item() { ft_sql "select id from item where mid = '$1' and uid = (select channel_id from channel where channel_address = '${2:-ftuser}') and item_deleted = 0 limit 1"; } +# ft_disconnect : the channel's connection to that address gone, so connecting again sends a new Follow +ft_disconnect() { forte_eval "use Code\\Lib\\Channel; \$c = Channel::from_username('$1'); \$r = q(\"select abook_id from abook join xchan on abook_xchan = xchan_hash where abook_channel = %d and (xchan_url = '%s' or xchan_hash = '%s' or xchan_addr = '%s')\", intval(\$c['channel_id']), dbesc('$2'), dbesc('$2'), dbesc('$2')); foreach (\$r ?: [] as \$a) contact_remove(\$c['channel_id'], \$a['abook_id']);"; } +# ft_connect
: the channel connects, and Forte's notifier sends its Follow at once (Forte starts it as a +# background process, which does not outlive a command run through podman exec) +ft_connect() { + local abook + abook=$(forte_eval "use Code\\Lib\\Connect; use Code\\Lib\\Channel; \$c = Channel::from_username('$1'); \$r = Connect::connect(\$c, '$2'); echo \$r['success'] ? \$r['abook']['abook_id'] : '';" | tail -1) + [ -n "$abook" ] && forte_php src/Daemon/Run.php Notifier permissions_create "$abook" >/dev/null 2>&1 + echo "$abook" +} +p_home_id() { curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(next((o['id'] for o in ((s.get('reblog') or s) for s in d) if '$1' in (o['content'] or '')), ''))"; } +p_status() { curl -s -H "$PH" "$P/api/v1/statuses/$1" | j "print(d.get('$2'))"; } +p_replies_have() { [ "$(curl -s -H "$PH" "$P/api/v1/statuses/$1/context" | j "print(any('$2' in s['content'] for s in d['descendants']))")" = "True" ]; } + +echo "forte" +[ -n "$(ftc ftuser "$FT/api/z/1.0/verify" | j "print(d.get('channel_address') or '')")" ] && ok "Forte's API as ftuser" \ + || { ko "Forte's API ($(ftc ftuser "$FT/api/z/1.0/verify" | head -c 200))"; return 1; } +PT=$(privapub_token alice_forte) +PH="Authorization: Bearer $PT" +[ -n "$PT" ] && ok "PrivaPub token for alice_forte" || { ko "PrivaPub token for alice_forte"; 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 " follows" +ft_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=ftuser@forte.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +[ -n "$ft_on_p" ] && ok "PrivaPub resolves ftuser" || ko "PrivaPub cannot resolve ftuser" +# (left from an earlier run: alice and ftuser unfollow each other and ftfriend leaves ftuser, so every follow below is new) +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ft_on_p/unfollow" +ft_disconnect ftuser "$alice_uri" >/dev/null; ft_disconnect ftuser "$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d['url'])")" >/dev/null +ft_disconnect ftfriend ftuser@forte.test >/dev/null; ft_disconnect ftuser ftfriend@forte.test >/dev/null +sleep 3 +ft_connect ftuser "$alice_uri" >/dev/null +until_true 60 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d[\"followers_count\"])")" -ge 1 ]' \ + && ok "ftuser follows alice" || ko "Forte's follow never reached alice" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ft_on_p/follow" +until_true 60 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$ft_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \ + && ok "alice follows ftuser (Accept arrived)" || ko "Forte's Accept never arrived" +# (ftfriend connects to ftuser now: a channel comments only on what reached it, and only once the other side has let it) +ft_connect ftfriend ftuser@forte.test >/dev/null + +echo " posts" +ftc ftuser -o /dev/null -X POST "$FT/api/z/1.0/item/update" --data-urlencode "body=a Forte post $run" +until_true 60 '[ -n "$(p_home_id "a Forte post $run")" ]' && ok "ftuser's post reaches alice's home" || ko "ftuser's post never reached alice" +ft_post=$(p_home_id "a Forte post $run") +ft_post_uri=$(p_status "$ft_post" uri) +p_post=$(curl -s -X POST -H "$PH" "$P/api/v1/statuses" -d "status=a PrivaPub post for Forte $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 60 '[ -n "$(ft_item "$p_post_uri")" ]' && ok "alice's post reaches ftuser" || ko "alice's post never reached Forte" + +echo " comments" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses" -d "status=@ftuser@forte.test a PrivaPub comment $run&in_reply_to_id=$ft_post&visibility=public" +until_true 60 '[ "$(ft_sql "select count(*) from item where body like '"'%a PrivaPub comment $run%'"' and thr_parent = '"'$ft_post_uri'"'")" -ge 1 ]' \ + && ok "alice's comment threads under ftuser's post" || ko "alice's comment never reached Forte" +ftc ftuser -o /dev/null -X POST "$FT/api/z/1.0/item/update" --data-urlencode "body=a Forte comment $run" -d "parent=$(ft_item "$p_post_uri")" +until_true 60 'p_replies_have "$p_post_id" "a Forte comment $run"' && ok "ftuser's comment threads under alice's post" || ko "ftuser's comment missing on PrivaPub" +# ftfriend, who alice does not follow, comments in ftuser's thread: Forte, the thread's owner, passes it on to alice +until_true 30 '[ -n "$(ft_item "$ft_post_uri" ftfriend)" ]' >/dev/null +ftc ftfriend -o /dev/null -X POST "$FT/api/z/1.0/item/update" --data-urlencode "body=a word from ftfriend $run" -d "parent=$(ft_item "$ft_post_uri" ftfriend)" +until_true 60 'p_replies_have "$ft_post" "a word from ftfriend $run"' \ + && ok "ftfriend's comment in ftuser's thread reaches alice, passed on" || ko "ftfriend's comment never reached alice" + +echo " likes" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$ft_post/favourite" +until_true 60 '[ "$(ft_sql "select count(*) from item where verb = '"'Like'"' and thr_parent = '"'$ft_post_uri'"' and item_deleted = 0")" -ge 1 ]' \ + && ok "alice's like lands on Forte" || ko "alice's like never reached Forte" +forte_web ftuser -o /dev/null "$FT/like/$(ft_item "$p_post_uri")?verb=Like" +until_true 60 '[ "$(p_status "$p_post_id" favourites_count)" = "1" ]' && ok "ftuser's like counts on PrivaPub" || ko "ftuser's like never counted" + +echo " edits and deletions" +ftc ftuser -o /dev/null -X POST "$FT/api/z/1.0/item/update" -d "post_id=$(ft_item "$ft_post_uri")" --data-urlencode "body=a Forte post $run, edited" +until_true 60 'p_status "$ft_post" content | grep -q "edited"' && ok "ftuser's edit reaches PrivaPub" || ko "ftuser's edit never reached PrivaPub" +curl -s -o /dev/null -X PUT -H "$PH" "$P/api/v1/statuses/$p_post_id" -d "status=a PrivaPub post for Forte $run, edited" +until_true 60 '[ "$(ft_sql "select count(*) from item where mid = '"'$p_post_uri'"' and body like '"'%edited%'"'")" -ge 1 ]' \ + && ok "alice's edit reaches Forte" || ko "alice's edit never reached Forte" +forte_web ftuser -o /dev/null "$FT/item/drop/$(ft_item "$ft_post_uri")" +until_true 60 '[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$PH" "$P/api/v1/statuses/$ft_post")" = "404" ]' \ + && ok "ftuser's deletion reaches PrivaPub" || ko "ftuser's deleted post still on PrivaPub" +curl -s -o /dev/null -X DELETE -H "$PH" "$P/api/v1/statuses/$p_post_id" +until_true 60 '[ -z "$(ft_item "$p_post_uri")" ]' && ok "alice's deletion reaches Forte" || ko "alice's deleted post still on Forte" + +echo " unfollow" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$ft_on_p/unfollow" +until_true 20 '[ "$(ft_sql "select count(*) from abconfig where xchan = '"'$alice_uri'"' and cat = '"'their_perms'"' and v = '"'1'"' and chan = (select channel_id from channel where channel_address = '"'ftuser'"')")" = "0" ]' \ + && ok "alice's unfollow reaches Forte" || ko "Forte keeps alice following ftuser after her Undo{Follow}" + +echo " statistics" +stats_check forte.test forte