Everything on, phase 2 completed: SecureMode on in production, checked by the deploy
Build / Build (push) Successful in 5m5s
Deploy / privapub.thepra.dev (push) Failing after 4m39s

Owner decision 2026-10-04: SecureMode on once the pasture passes with it.

- Both clean pasture passes were run over all six peers:
  - normally: 246 passed, 0 failed;
  - with Federation__SecureMode=true: every federation check passed. The only failures were four checks expecting an
    unsigned GET to get 404 or 410 where SecureMode answers 401. Those checks now go through `unserved` and
    `gone_unsigned` (lib/interop.sh), which expect 401 when SecureMode is on.
- Circle posts now reach their member on GoToSocial and Mastodon, and survive Mastodon's signed refetch, as does a
  followers-only post. The GoToSocial expected failure is gone.
- appsettings.Production.json turns SecureMode on.
- The deploy now checks that an unsigned GET of @thepra answers 401 and that a browser is redirected. It reads
  @thepra's discoverability through the Mastodon API, since the actor is no longer readable unsigned.
- docs/INTEROP.md (Mastodon, GoToSocial), CLAUDE.md and ROADMAP updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-04 03:34:48 +02:00
1 parent 8c2eba6cbb
commit f6dbf71964
8 files changed
+47 -14

No files matched your search

+6 -1
View File
@@ -95,6 +95,11 @@ jobs:
[ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; } [ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/privapub") code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/privapub")
[ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; } [ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; }
# SecureMode (owner decision 2026-10-04): an unsigned reader gets 401 from a persona, a browser the public page
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/thepra")
[ "$code" = "401" ] || { echo "::error::an unsigned GET of a persona answered $code, not 401: SecureMode is off"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: text/html' "$PUBLIC_URL/peasants/thepra")
[ "$code" = "302" ] || { echo "::error::a browser asking for a persona got $code, not a redirect"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo") code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo")
[ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; } [ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/swagger/index.html") code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/swagger/index.html")
@@ -123,7 +128,7 @@ jobs:
read -r token cid cs <<< "$(tools/smoke/oauth.sh "$PUBLIC_URL" "$login" "$password" thepra read)" read -r token cid cs <<< "$(tools/smoke/oauth.sh "$PUBLIC_URL" "$login" "$password" thepra read)"
echo "::add-mask::$token" echo "::add-mask::$token"
tools/smoke/mastodon-api.sh "$PUBLIC_URL" "$token" tools/smoke/mastodon-api.sh "$PUBLIC_URL" "$token"
discoverable=$(curl -fsS -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/thepra" | python3 -c "import json,sys; print(json.load(sys.stdin).get('discoverable'))") discoverable=$(curl -fsS -H "Authorization: Bearer $token" "$PUBLIC_URL/api/v1/accounts/verify_credentials" | python3 -c "import json,sys; print(json.load(sys.stdin).get('discoverable'))")
[ "$discoverable" = "False" ] || { echo "::error::@thepra is discoverable"; exit 1; } [ "$discoverable" = "False" ] || { echo "::error::@thepra is discoverable"; exit 1; }
curl -fsS -o /dev/null -X POST "$PUBLIC_URL/oauth/revoke" --data-urlencode "token=$token" \ curl -fsS -o /dev/null -X POST "$PUBLIC_URL/oauth/revoke" --data-urlencode "token=$token" \
--data-urlencode "client_id=$cid" --data-urlencode "client_secret=$cs" --data-urlencode "client_id=$cid" --data-urlencode "client_secret=$cs"
+7 -2
View File
@@ -431,9 +431,11 @@ tools/pasture/run.sh down # removes e
delete is checked as a 404 on `/api/v1/statuses/{id}`. delete is checked as a 404 on `/api/v1/statuses/{id}`.
- It creates its accounts locked, so the scenario approves alice's request through `/api/v1/follow_requests`, which - It creates its accounts locked, so the scenario approves alice's request through `/api/v1/follow_requests`, which
also checks our pending (`requested`) state and the manual Accept. also checks our pending (`requested`) state and the manual Accept.
- 37 checks: discovery and follows both ways; posts and CW; a reply and its notification; likes and boosts both - 55 checks: discovery and follows both ways; posts and CW; a reply and its notification; likes and boosts both
ways; DMs both ways and off public timelines; polls both ways; quote policy; link cards; edits and deletes both ways; DMs both ways and off public timelines; polls both ways; quote policy; link cards; edits and deletes both
ways; unfollow; block and unblock; statistics. ways; communities and circles; locked personas; unfollow; block and unblock; statistics.
- It files a circle post as a direct message and shows it only to the accounts it mentions, so like a DM it is
checked in the member's `/api/v1/conversations`, never by URI.
- **Mastodon (4.7.3):** web and sidekiq on the shared Postgres and Redis, `ALLOWED_PRIVATE_ADDRESSES` for the network. - **Mastodon (4.7.3):** web and sidekiq on the shared Postgres and Redis, `ALLOWED_PRIVATE_ADDRESSES` for the network.
Its token comes from `rails runner` (no password grant). Without Elasticsearch its status search finds nothing, so Its token comes from `rails runner` (no password grant). Without Elasticsearch its status search finds nothing, so
deliveries are checked through `/api/v1/accounts/:id/statuses` of the sender as Mastodon knows them, or deliveries are checked through `/api/v1/accounts/:id/statuses` of the sender as Mastodon knows them, or
@@ -461,6 +463,9 @@ tools/pasture/run.sh down # removes e
its own activity and every bare `Announce{object}` 400 by design, and the echo is still needed (see its own activity and every bare `Announce{object}` 400 by design, and the echo is still needed (see
`docs/INTEROP.md`, Lemmy). A new Lemmy never sends what it queued for a server before its send worker for that `docs/INTEROP.md`, Lemmy). A new Lemmy never sends what it queued for a server before its send worker for that
server started, so the scenario waits for that worker (`lm_worker`) before its first follow. 20 checks; relayed votes and a moderator's removal are expected failures (P7). server started, so the scenario waits for that worker (`lm_worker`) before its first follow. 20 checks; relayed votes and a moderator's removal are expected failures (P7).
- **SecureMode:** `PRIVAPUB_ENV="Federation__SecureMode=true" run.sh up …`, as production runs. A check of what an
unsigned reader sees uses `unserved` and `gone_unsigned` (`lib/interop.sh`), which expect 401 when SecureMode is on and
404 or 410 when it is off.
- **Crawler:** `PRIVAPUB_ENV="Statistics__Crawler__Enabled=true Statistics__Crawler__Seeds__0=mastodon.test" - **Crawler:** `PRIVAPUB_ENV="Statistics__Crawler__Enabled=true Statistics__Crawler__Seeds__0=mastodon.test"
run.sh up mastodon`, then `interop.sh crawler`. `PRIVAPUB_ENV` passes any setting to the PrivaPub container. run.sh up mastodon`, then `interop.sh crawler`. `PRIVAPUB_ENV` passes any setting to the PrivaPub container.
- `run.sh up` replaces every container, Mongo included, so each run starts clean. To keep the data, republish into - `run.sh up` replaces every container, Mongo included, so each run starts clean. To keep the data, republish into
+3
View File
@@ -5,6 +5,9 @@
"Registrations": { "Registrations": {
"Mode": "Invitations" "Mode": "Invitations"
}, },
"Federation": {
"SecureMode": true
},
"Statistics": { "Statistics": {
"Crawler": { "Crawler": {
"Enabled": true, "Enabled": true,
+11 -4
View File
@@ -169,15 +169,19 @@ Priorities, used throughout:
Findings: Findings:
- **Mastodon 4.7 names its actors by number**: `https://mastodon.test/ap/users/<id>`, inbox `…/ap/users/<id>/inbox`. Nothing here may assume - **Mastodon 4.7 names its actors by number**: `https://mastodon.test/ap/users/<id>`, inbox `…/ap/users/<id>/inbox`. Nothing here may assume
`/users/<name>`. `/users/<name>`.
- **It drops circle posts** (expected failure in the scenario). - **It dropped circle posts** until each member's copy named that member (owner decision 2026-10-04, v1.19.0).
- A post addressed only to `[circle, circle/flock]` parses as `direct` there, and Mastodon keeps a `direct` post - A post addressed only to `[circle, circle/flock]` parses as `direct` there, and Mastodon keeps a `direct` post
only if it names a local account or arrived in a known account's inbox (`Create#addresses_local_accounts?`). only if it names a local account or arrived in a known account's inbox (`Create#addresses_local_accounts?`).
- But `ActivityPub::InboxesController#account_required?` looks only at `params[:account_username]`. A delivery to - But `ActivityPub::InboxesController#account_required?` looks only at `params[:account_username]`. A delivery to
the numeric `/ap/users/:account_id/inbox` it now advertises therefore reaches the worker with no recipient and the numeric `/ap/users/:account_id/inbox` it now advertises therefore reaches the worker with no recipient and
is rejected. is rejected.
- DMs are unaffected because they name the recipient. - DMs are unaffected because they name the recipient.
- Fixing it on our side means naming the member (or that server's members) in each copy's `cc`, which changes what a - Each member's copy now names that member in `cc` and mentions them silently, so Mastodon keeps it, and its signed
circle reveals: **owner decision pending**. Reporting it upstream is the other half. refetch (`ActivityPub::FetchRemoteStatusService`, by its instance actor) gets the post back naming the members on
that server. The same holds for a followers-only post's refetch, which used to answer 404, and a 404 on refetch
makes Mastodon delete its copy. Reporting the numeric-inbox recipient loss upstream is still worth doing.
- **With SecureMode on** (all six peers, 2026-10-04) every federation check passes: Mastodon, GoToSocial, Misskey,
Sharkey, Akkoma and Lemmy all sign their fetches, and fetch our instance actor's key unsigned first.
- Inbound `Block` from Mastodon is not enforced yet (P7). - Inbound `Block` from Mastodon is not enforced yet (P7).
### GoToSocial: 0.22.1 (2026-07-20) ### GoToSocial: 0.22.1 (2026-07-20)
@@ -233,7 +237,10 @@ Findings:
**Pasture evidence (2026-10-03, GoToSocial 0.22.1, `tools/pasture/scenarios/gts.sh`):** 37 checks pass, three runs in a **Pasture evidence (2026-10-03, GoToSocial 0.22.1, `tools/pasture/scenarios/gts.sh`):** 37 checks pass, three runs in a
row. That is the original 33 plus four on statistics: described as gotosocial, inbound and outbound traffic counted, row. That is the original 33 plus four on statistics: described as gotosocial, inbound and outbound traffic counted,
no account named. no account named. Since 2026-10-04 (v1.19.0) circle posts reach a GoToSocial member too. GoToSocial files a post for
neither the public nor the author's followers as a direct message, like our DMs, and shows it only to the accounts it
mentions. Being in `cc` stored it but left it invisible, so each member's copy also mentions that member silently.
Such posts are then found in the member's conversations, never by a search on their URI.
### Misskey family: Misskey 2026.10.0, Sharkey 2025.4.7, Iceshrimp.NET 2026.1.2-beta, CherryPick 4.17 ### Misskey family: Misskey 2026.10.0, Sharkey 2025.4.7, Iceshrimp.NET 2026.1.2-beta, CherryPick 4.17
+9 -3
View File
@@ -31,9 +31,15 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
Both open items were closed without a root step (owner decisions, 2026-10-04): the server fetches its geolocation Both open items were closed without a root step (owner decisions, 2026-10-04): the server fetches its geolocation
databases itself, and the deploy makes and signs in as @thepra. databases itself, and the deploy makes and signs in as @thepra.
- [ ] Everything on in production (owner decisions 2026-10-04): v1.18.0 self-updating geolocation, @thepra, the crawler - [ ] Everything on in production (owner decisions 2026-10-04):
on, sign-up by invitation, one registrations switch; then signed audiences with circles for everyone and SecureMode on, - v1.18.0, deployed and verified 2026-10-04: geolocation that updates itself (DB-IP Lite 2026-10 loaded), the deploy
account privacy, and one answer everywhere (the mismatch sweep). signing in as @thepra (undiscoverable), the crawler on (1010 servers known within the hour), sign-up by invitation
with one registrations switch;
- v1.19.0, 2026-10-04: circle posts reach Mastodon and GoToSocial members (each copy names and mentions its member),
followers-only, direct and circle posts served to signed refetches from those they were for, SecureMode on (all six
pasture peers pass under it), and account privacy (sign-in and recovery say nothing, recovery codes hashed for an
hour, a recovered password ends every session, a deleted root's personas and groups are deleted everywhere);
- still to come: one answer everywhere (the mismatch sweep).
- [ ] P7 Threads, communities, moderation, the social graph - [ ] P7 Threads, communities, moderation, the social graph
- [ ] P8 Signatures, discovery, the long tail - [ ] P8 Signatures, discovery, the long tail
+7
View File
@@ -15,6 +15,13 @@ site() { curl -k --resolve "$1:6443:127.0.0.1" "${@:2}"; }
pfetch() { curl -sk --connect-to privapub.test:443:127.0.0.1:6443 "$@"; } pfetch() { curl -sk --connect-to privapub.test:443:127.0.0.1:6443 "$@"; }
# make_png <path>: an 8x8 red PNG, for uploads # make_png <path>: an 8x8 red PNG, for uploads
# the status an unsigned ActivityPub GET of a PrivaPub document gets
pstatus() { pfetch -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$1"; }
# SecureMode (PRIVAPUB_ENV="Federation__SecureMode=true") answers every unsigned GET but the instance actor's 401, so a
# post's author answering 401 means it is on; then 401 is what "not served" and "gone" look like to an unsigned reader
secure_mode() { [ "$(pstatus "${1%%/scribbles/*}")" = "401" ]; }
unserved() { local code; code=$(pstatus "$1"); if secure_mode "$1"; then [ "$code" = 401 ]; else [ "$code" = 404 ]; fi; }
gone_unsigned() { local code; code=$(pstatus "$1"); if secure_mode "$1"; then [ "$code" = 401 ]; else [ "$code" = 410 ]; fi; }
make_png() { python3 -c " make_png() { python3 -c "
import struct,zlib import struct,zlib
w=h=8 w=h=8
+1 -1
View File
@@ -170,7 +170,7 @@ circle_uri=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db
# is then in gtsuser's conversations, never in a search by URI. # is then in gtsuser's conversations, never in a search by URI.
until_true 15 'gcurl -s -H "$GH" "$G/api/v1/conversations" | grep -q "only the circle on GoToSocial sees this"' \ until_true 15 'gcurl -s -H "$GH" "$G/api/v1/conversations" | grep -q "only the circle on GoToSocial sees this"' \
&& ok "a circle post, naming its member, reaches its GoToSocial member" || ko "circle post missing on GoToSocial" && ok "a circle post, naming its member, reaches its GoToSocial member" || ko "circle post missing on GoToSocial"
[ "$(pfetch -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$circle_uri")" = "404" ] && ok "the circle post is not served unsigned" || ko "circle post served unsigned" unserved "$circle_uri" && ok "the circle post is not served unsigned" || ko "circle post served unsigned"
echo "locked personas" echo "locked personas"
LT=$(privapub_token locked_alice); LH="Authorization: Bearer $LT" LT=$(privapub_token locked_alice); LH="Authorization: Bearer $LT"
+3 -3
View File
@@ -39,7 +39,7 @@ cw_uri=$(curl -s -X POST -H "$AH" $P/api/v1/statuses -d 'status=behind a warning
until_true 30 '[ "$(m_status_by_uri "$alice_on_m" "$cw_uri" | j "print(d and d[\"spoiler_text\"]==\"spoilers\" and d[\"sensitive\"])")" = "True" ]' && ok "a content warning survives to Mastodon" || ko "content warning lost on Mastodon" until_true 30 '[ "$(m_status_by_uri "$alice_on_m" "$cw_uri" | j "print(d and d[\"spoiler_text\"]==\"spoilers\" and d[\"sensitive\"])")" = "True" ]' && ok "a content warning survives to Mastodon" || ko "content warning lost on Mastodon"
fo_uri=$(curl -s -X POST -H "$AH" $P/api/v1/statuses -d 'status=only for followers&visibility=private' | j "print(d['uri'])") fo_uri=$(curl -s -X POST -H "$AH" $P/api/v1/statuses -d 'status=only for followers&visibility=private' | j "print(d['uri'])")
until_true 30 '[ "$(m_status_by_uri "$alice_on_m" "$fo_uri" | j "print(d and d[\"visibility\"])")" = "private" ]' && ok "a followers-only post reaches Mastodon as private" || ko "followers-only post missing or widened on Mastodon" until_true 30 '[ "$(m_status_by_uri "$alice_on_m" "$fo_uri" | j "print(d and d[\"visibility\"])")" = "private" ]' && ok "a followers-only post reaches Mastodon as private" || ko "followers-only post missing or widened on Mastodon"
[ "$(pfetch -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$fo_uri")" = "404" ] && ok "the followers-only post is not served unsigned" || ko "followers-only post served unsigned" unserved "$fo_uri" && ok "the followers-only post is not served unsigned" || ko "followers-only post served unsigned"
# Mastodon deletes its copy when a refetch answers 404, so a signed refetch from a follower's server is answered # Mastodon deletes its copy when a refetch answers 404, so a signed refetch from a follower's server is answered
fo_refetch=$(podman exec pasture-mastodon bin/rails runner "s = ActivityPub::FetchRemoteStatusService.new.call('$fo_uri'); puts(s.present? ? s.visibility : 'lost')" 2>/dev/null | tail -1) fo_refetch=$(podman exec pasture-mastodon bin/rails runner "s = ActivityPub::FetchRemoteStatusService.new.call('$fo_uri'); puts(s.present? ? s.visibility : 'lost')" 2>/dev/null | tail -1)
[ "$fo_refetch" = "private" ] && ok "Mastodon's signed refetch of the followers-only post keeps it private" || ko "Mastodon's refetch of the followers-only post: $fo_refetch" [ "$fo_refetch" = "private" ] && ok "Mastodon's signed refetch of the followers-only post keeps it private" || ko "Mastodon's refetch of the followers-only post: $fo_refetch"
@@ -115,7 +115,7 @@ cw_on_m=$(m_status_by_uri "$alice_on_m" "$cw_uri" | j "print(d['id'])")
cw_id=$(curl -s -H "$AH" "$P/api/v1/accounts/$a_poll_id/statuses" | j "print(next(s['id'] for s in d if s['uri']=='$cw_uri'))") cw_id=$(curl -s -H "$AH" "$P/api/v1/accounts/$a_poll_id/statuses" | j "print(next(s['id'] for s in d if s['uri']=='$cw_uri'))")
curl -s -o /dev/null -X DELETE -H "$AH" "$P/api/v1/statuses/$cw_id" curl -s -o /dev/null -X DELETE -H "$AH" "$P/api/v1/statuses/$cw_id"
until_true 30 '[ "$(mcurl -o /dev/null -w "%{http_code}" -H "$MH" "$M/api/v1/statuses/$cw_on_m")" = "404" ]' && ok "alice_masto's delete reaches Mastodon" || ko "delete not applied on Mastodon" until_true 30 '[ "$(mcurl -o /dev/null -w "%{http_code}" -H "$MH" "$M/api/v1/statuses/$cw_on_m")" = "404" ]' && ok "alice_masto's delete reaches Mastodon" || ko "delete not applied on Mastodon"
[ "$(pfetch -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$cw_uri")" = "410" ] && ok "the deleted post answers 410" || ko "deleted post does not answer 410" gone_unsigned "$cw_uri" && ok "the deleted post answers 410" || ko "deleted post does not answer 410"
mcurl -o /dev/null -X DELETE -H "$MH" "$M/api/v1/statuses/$m_poll" mcurl -o /dev/null -X DELETE -H "$MH" "$M/api/v1/statuses/$m_poll"
until_true 30 '! curl -s -H "$AH" "$P/api/v1/timelines/home" | grep -q "Mastodon asks"' && ok "mastouser's delete reaches PrivaPub" || ko "Mastodon's delete not applied on PrivaPub" until_true 30 '! curl -s -H "$AH" "$P/api/v1/timelines/home" | grep -q "Mastodon asks"' && ok "mastouser's delete reaches PrivaPub" || ko "Mastodon's delete not applied on PrivaPub"
@@ -159,7 +159,7 @@ OT=$(mastodon_token outsider)
alice_on_m_o=$(mcurl -H "Authorization: Bearer $OT" "$M/api/v2/search?q=@alice_masto@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") alice_on_m_o=$(mcurl -H "Authorization: Bearer $OT" "$M/api/v2/search?q=@alice_masto@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ "$(mcurl -H "Authorization: Bearer $OT" "$M/api/v1/accounts/$alice_on_m_o/statuses?limit=40" | j "print(any(s['uri']=='$circle_uri' for s in d))")" = "False" ] \ [ "$(mcurl -H "Authorization: Bearer $OT" "$M/api/v1/accounts/$alice_on_m_o/statuses?limit=40" | j "print(any(s['uri']=='$circle_uri' for s in d))")" = "False" ] \
&& ok "another Mastodon user does not see the circle post" || ko "the circle post leaked to a non-member" && ok "another Mastodon user does not see the circle post" || ko "the circle post leaked to a non-member"
[ "$(pfetch -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$circle_uri")" = "404" ] && ok "the circle post is not served unsigned" || ko "circle post served unsigned" unserved "$circle_uri" && ok "the circle post is not served unsigned" || ko "circle post served unsigned"
echo " reports" echo " reports"
curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/reports" -d "account_id=$masto_on_p&comment=pasture-report-$circle_name&forward=true" curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/reports" -d "account_id=$masto_on_p&comment=pasture-report-$circle_name&forward=true"