Forte in the pasture

Forte 26.9.10, the ActivityPub-only fork of Streams, built from its tag (images/forte: composer on Hubzilla's PHP image)
with portable identities (did:key actors behind /.well-known/apgateway). scenarios/forte.sh, 21 checks: follows, posts,
comments, likes, edits and deletions both ways, a third channel's comment passed on by the thread's owner, the
unfollow, statistics. INTEROP.md records what it does its own way: follows of profile pages, edits only as
Add{Update} under the Create's id, collections that misname themselves.

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-06 10:58:45 +02:00
1 parent 99b5e2dbf3
commit 3df3c333bf
8 files changed
+317

No files matched your search

+6
View File
@@ -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 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 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. 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 - **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 (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` cron sidecar running `Zotlabs/Daemon/Master.php Cron` each minute. Its addons `pubcrawl` (ActivityPub) and `statistics`
+1
View File
@@ -45,6 +45,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d
- **BookWyrm 0.9.3** - **BookWyrm 0.9.3**
- **Vernissage 1.43.0** - **Vernissage 1.43.0**
- **Hubzilla 11.4.1** with its pubcrawl addon - **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 - **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**, - 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**
+20
View File
@@ -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 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). reaches Mastodon through both (forwarded on its proof, and announced).
### Forte 26.9.10
- **Portable identities (FEP-ef61):** a channel's actor is `https://<server>/.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) ### Hubzilla 11.4.1 (pubcrawl)
- **Channels speak ActivityPub only with the "Activitypub Protocol" app** installed for them (the pubcrawl addon on - **Channels speak ActivityPub only with the "Activitypub Protocol" app** installed for them (the pubcrawl addon on
+5
View File
@@ -108,6 +108,11 @@ nodebb.test {
reverse_proxy pasture-nodebb:4567 reverse_proxy pasture-nodebb:4567
} }
forte.test {
tls internal
reverse_proxy pasture-forte:8080
}
hubzilla.test { hubzilla.test {
tls internal tls internal
reverse_proxy pasture-hubzilla:8080 reverse_proxy pasture-hubzilla:8080
+14
View File
@@ -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
+69
View File
@@ -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;
}
}
}
+103
View File
@@ -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>: 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 <<EOF
<?php
\$db_host = 'mysql';
\$db_port = 3306;
\$db_user = 'pasture';
\$db_pass = 'pasture';
\$db_data = 'forte';
\$db_type = 0;
\$db_vrsn = '8.4';
App::\$config['system']['timezone'] = 'UTC';
App::\$config['system']['baseurl'] = 'https://forte.test';
App::\$config['system']['sitename'] = 'Pasture Forte';
App::\$config['system']['location_hash'] = '$(openssl rand -hex 32)';
App::\$config['system']['transport_security_header'] = 1;
App::\$config['system']['content_security_policy'] = 1;
App::\$config['system']['ssl_cookie_protection'] = 1;
App::\$config['system']['register_policy'] = REGISTER_OPEN;
App::\$config['system']['register_text'] = '';
App::\$config['system']['admin_email'] = 'ftuser@forte.test';
App::\$config['system']['verify_email'] = 0;
App::\$config['system']['php_path'] = 'php';
App::\$config['system']['directory_mode'] = DIRECTORY_MODE_STANDALONE;
EOF
}
forte_up() {
shared_mysql_up
mysql_db forte
mkdir -p "$here/.state/forte"
[ -s "$here/.state/forte/htconfig.php" ] || forte_config > "$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 <nick>: an account (<nick>@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 <nick> <curl args...>: a request to Forte's web pages as <nick>, 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" "$@"
}
+99
View File
@@ -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 <nick> <curl args...>: Forte's API as <nick>
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 <mid> [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 <nick> <url>: 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 <nick> <address>: 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