Persona separation is tested, the API is smoke-checked on deploy, OAuth rows are pruned
Build / Build (push) Successful in 59s
Deploy / privapub.thepra.dev (push) Successful in 1m13s

- PersonaSeparationTests: two personas of one login follow the same
  account and post; nothing the API maps for one contains the other's id,
  username or the root id.
- tools/smoke/mastodon-api.sh checks what a client meets first (instance
  v1/v2, discovery, app registration, client credentials, an app token
  refused by a user endpoint, the public timeline, revocation) and, given
  a persona token, verify_credentials, home and notifications. deploy.yml
  runs it after every deploy.
- OAuthPruner removes invalid tokens and authorizations older than two
  weeks, every six hours.
- The consent page no longer sets form-action, which browsers apply to the
  redirect back to the client after the form is posted.

CLAUDE.md gains the Mastodon API layout and invariants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 12:09:19 +02:00
1 parent 70b4c45eb0
commit d8f163b5ce
7 files changed
+157 -3

No files matched your search

+1
View File
@@ -104,4 +104,5 @@ jobs:
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \ code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \
--data '{"type":"Follow","actor":"https://example.org/users/x","object":"'"$PUBLIC_URL"'/peasants/privapub"}' "$PUBLIC_URL/human-centipede") --data '{"type":"Follow","actor":"https://example.org/users/x","object":"'"$PUBLIC_URL"'/peasants/privapub"}' "$PUBLIC_URL/human-centipede")
[ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; } [ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; }
tools/smoke/mastodon-api.sh "$PUBLIC_URL"
echo "::notice::serving $served" echo "::notice::serving $served"
+22 -2
View File
@@ -90,7 +90,14 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Social/ FollowService (local in-process, remote Follow/Accept), Notifications Social/ FollowService (local in-process, remote Follow/Accept), Notifications
Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService
Privacy/ VisibilityPolicy (IsPublic expression, CanSee) Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex) Domain/Statuses/ StatusService: publish, edit, remove, favourite, reblog, for a persona (both client APIs use it)
Api/Mastodon/
Auth/ OpenIddict setup (keys in Mongo), MastodonScopes, TokenController, OAuthPruner
Infrastructure/ MastodonController (avatar context, scopes, errors, Link), MastodonParams, MastodonJson, Page
Entities/ Mappers/ Mastodon entities; MastodonMapper (Account, Status), AccountSearch
Controllers/ apps, instance, accounts, statuses, timelines, notifications, search, stubs
Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex);
OAuth/: /oauth/login (root password), /oauth/authorize (choose persona, consent)
Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, … Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, …
Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration
StaticServices/ DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher StaticServices/ DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher
@@ -171,6 +178,18 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
13. **Home timelines are written, not computed:** every stored or created post goes through `Fanout.Distribute`, and 13. **Home timelines are written, not computed:** every stored or created post goes through `Fanout.Distribute`, and
every delete removes its `TimelineEntry` rows. Local deletes are soft (content cleared, 410 Tombstone). every delete removes its `TimelineEntry` rows. Local deletes are soft (content cleared, 410 Tombstone).
## Mastodon client API invariants
1. **A token is one persona.** Its subject is the avatar id; the root id lives only in the fifteen-minute `/oauth`
cookie used while choosing the persona, and never in a token, an authorization or a response.
2. `/api/*` authenticates with OpenIddict validation, everything else with the old JWT (`PrivaPub` policy scheme).
Every `/api` request re-checks that the persona's root is neither banned nor deleted (`MastodonController`).
3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read
from a conditional must say `(int?)null`, not `default`: that bug once made every list one item long.
4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`.
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade.
6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist.
## Privacy invariants ## Privacy invariants
- **No root id in federation output, NodeInfo or logs, no IP next to an identity in logs, and no `ex.Message` to a - **No root id in federation output, NodeInfo or logs, no IP next to an identity in logs, and no `ex.Message` to a
@@ -233,7 +252,8 @@ the owner's GoToSocial at social.arasaka.software. **Ask before acting from the
- **CI/CD:** push to `master` runs `build.yml` (build + tests) on the instance-wide `build` runner. A `v*` tag runs - **CI/CD:** push to `master` runs `build.yml` (build + tests) on the instance-wide `build` runner. A `v*` tag runs
`deploy.yml`: tests, self-contained linux-x64 publish, snapshot and `mongodump` to `/var/backups/privapub.thepra.dev`, `deploy.yml`: tests, self-contained linux-x64 publish, snapshot and `mongodump` to `/var/backups/privapub.thepra.dev`,
stop → rsync → start, a `127.0.0.1:6970/build.json` health loop with rollback, then public checks (actor, NodeInfo, stop → rsync → start, a `127.0.0.1:6970/build.json` health loop with rollback, then public checks (actor, NodeInfo,
Swagger 404, inbox junk 400, unsigned 401). Swagger 404, inbox junk 400, unsigned 401) and `tools/smoke/mastodon-api.sh` (app registration, client credentials,
discovery, instance, public timeline; pass a persona token as a second argument to check the signed-in side).
- **The box:** Max (`nuvola.xyz`). Unit `privapub` runs as www-data from `/var/www/privapub.thepra.dev` with - **The box:** Max (`nuvola.xyz`). Unit `privapub` runs as www-data from `/var/www/privapub.thepra.dev` with
`ASPNETCORE_ENVIRONMENT=Production`. `ASPNETCORE_ENVIRONMENT=Production`.
- **One-time root setup:** `deploy/max/setup.sh`, run through `../arasaka.software/tools/max/run.sh`. - **One-time root setup:** `deploy/max/setup.sh`, run through `../arasaka.software/tools/max/run.sh`.
@@ -0,0 +1,61 @@
using Microsoft.Extensions.Caching.Memory;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.ClientModels.Post;
using PrivaPub.ClientModels.Social;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json;
namespace PrivaPub.Tests.Api
{
[Trait("Category", "Integration")]
public sealed class PersonaSeparationTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
[Fact]
public async Task Nothing_shown_to_one_persona_names_its_sibling()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var (_, sibling) = await _harness.Persona("sibling", root);
var (bobRoot, bob) = await _harness.Persona("bob");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token);
await _harness.Follows.Follow(root, new FollowForm { AvatarId = sibling.Id, Target = bob.UserName }, token);
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = sibling.Id, Text = "from the sibling" }, token);
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "from alice" }, token);
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = "hi both" }, token);
var mapper = new MastodonMapper(_harness.Db, _harness.Local);
var account = await mapper.Local(alice, withSource: true, token);
var homeIds = (await _harness.Db.TimelineEntries.Match(e => e.AvatarId == alice.Id).ExecuteAsync(token)).Select(e => e.PostId).ToList();
var home = await _harness.Db.Posts.Match(p => homeIds.Contains(p.ID)).ExecuteAsync(token);
var statuses = await mapper.Statuses(home, alice.Id, token);
var bobAsSeenByAlice = await mapper.Account(bob.Id, token);
var json = JsonSerializer.Serialize(new object[] { account, statuses, bobAsSeenByAlice }, MastodonJson.Options);
Assert.DoesNotContain(sibling.Id, json);
Assert.DoesNotContain(sibling.UserName, json);
Assert.DoesNotContain(root, json);
Assert.Contains("hi both", json);
Assert.Equal(2, bobAsSeenByAlice.FollowersCount);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using OpenIddict.Abstractions;
namespace PrivaPub.Api.Mastodon.Auth
{
public class OAuthPruner : BackgroundService
{
static readonly TimeSpan Interval = TimeSpan.FromHours(6);
static readonly TimeSpan Threshold = TimeSpan.FromDays(14);
readonly IServiceProvider _services;
readonly ILogger<OAuthPruner> _logger;
public OAuthPruner(IServiceProvider services, ILogger<OAuthPruner> logger)
{
_services = services;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(Interval, stoppingToken);
using var scope = _services.CreateScope();
var threshold = DateTimeOffset.UtcNow - Threshold;
var tokens = await scope.ServiceProvider.GetRequiredService<IOpenIddictTokenManager>().PruneAsync(threshold, stoppingToken);
var authorizations = await scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>().PruneAsync(threshold, stoppingToken);
if (tokens + authorizations > 0)
_logger.LogInformation("{Service} removed {Tokens} tokens and {Authorizations} authorizations", nameof(OAuthPruner), tokens, authorizations);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "{Service} pass failed", nameof(OAuthPruner));
}
}
}
}
}
+1
View File
@@ -26,6 +26,7 @@ namespace PrivaPub.Api.Mastodon.Auth
public static IServiceCollection PrivaPubOAuth(this IServiceCollection services, IWebHostEnvironment environment) public static IServiceCollection PrivaPubOAuth(this IServiceCollection services, IWebHostEnvironment environment)
{ {
services.AddSingleton<IMongoDatabase>(_ => DB.Default.Database()); services.AddSingleton<IMongoDatabase>(_ => DB.Default.Database());
services.AddHostedService<OAuthPruner>();
services.AddAuthentication() services.AddAuthentication()
.AddCookie(LoginScheme, options => .AddCookie(LoginScheme, options =>
+1 -1
View File
@@ -147,7 +147,7 @@ namespace PrivaPub.Web.Pages.OAuth
.Where(p => p.Key is not ("avatarId" or "decision" or "signed_in")) .Where(p => p.Key is not ("avatarId" or "decision" or "signed_in"))
.Select(p => new KeyValuePair<string, string>(p.Key, (string)p.Value)) .Select(p => new KeyValuePair<string, string>(p.Key, (string)p.Value))
.ToList(); .ToList();
Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'"; Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'";
Response.Headers["Cache-Control"] = "no-store"; Response.Headers["Cache-Control"] = "no-store";
return Page(); return Page();
} }
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Checks the Mastodon client API the way a client first meets it.
# usage: tools/smoke/mastodon-api.sh https://privapub.thepra.dev [ACCESS_TOKEN]
set -euo pipefail
BASE="${1:?base url}"; TOKEN="${2:-}"
fail() { echo "::error::$*"; exit 1; }
json() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
version=$(curl -fsS "$BASE/api/v1/instance" | json "print(d['version'])") || fail "instance v1"
curl -fsS "$BASE/api/v2/instance" | json "assert d['configuration']['statuses']['max_characters'] > 0" || fail "instance v2"
curl -fsS "$BASE/.well-known/oauth-authorization-server" | json "assert d['token_endpoint'].endswith('/oauth/token')" || fail "oauth discovery"
app=$(curl -fsS -X POST "$BASE/api/v1/apps" -d 'client_name=privapub-smoke&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read') || fail "app registration"
id=$(echo "$app" | json "print(d['client_id'])"); secret=$(echo "$app" | json "print(d['client_secret'])")
app_token=$(curl -fsS -X POST "$BASE/oauth/token" -d "grant_type=client_credentials&client_id=$id&client_secret=$secret&scope=read" | json "print(d['access_token'])") || fail "client credentials"
curl -fsS -H "Authorization: Bearer $app_token" "$BASE/api/v1/apps/verify_credentials" | json "assert d['name'] == 'privapub-smoke'" || fail "app verify_credentials"
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $app_token" "$BASE/api/v1/accounts/verify_credentials")
[ "$code" = "401" ] || fail "an app token reached a user endpoint ($code)"
curl -fsS "$BASE/api/v1/timelines/public?limit=2" | json "assert isinstance(d, list)" || fail "public timeline"
curl -fsS -X POST "$BASE/oauth/revoke" -d "token=$app_token&client_id=$id&client_secret=$secret" -o /dev/null || fail "revoke"
if [ -n "$TOKEN" ]; then
curl -fsS -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/accounts/verify_credentials" | json "assert d['source'] is not None" || fail "verify_credentials"
curl -fsS -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/timelines/home?limit=5" | json "assert isinstance(d, list)" || fail "home"
curl -fsS -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/notifications?limit=5" | json "assert isinstance(d, list)" || fail "notifications"
fi
echo "mastodon api ok: $version"