R9 Web Push, then the honest version: VAPID key, push subscriptions, encrypted pushes, 4.5.0 advertised

The server's VAPID key (PushKeys, made on first need, in Mongo) is the instance API's vapid.public_key and every app's
vapid_key. /api/v1/push/subscription (POST, GET, PUT, DELETE) keeps one subscription per access token, so per persona;
every notification kept (never a filtered one) is queued as a SendPush job for the subscriptions whose alert and policy
admit it, and sent through the `push` client (the safe handler, https only) encrypted as RFC 8291 aes128gcm or the
aesgcm draft with an ES256 VAPID JWT. 404/410 delete the subscription, 429/5xx/timeouts retry, other answers drop the
push. Subscriptions end with their tokens (revocation, sessions ended, suspension, deletion, a restore); backups hold
neither keys nor subscriptions. The walk of every Mastodon 4.3-4.5 client route (docs/MASTODON-API.md) added
conversations/:id/unread, domain_blocks/preview, v2 notifications clear and PUT policy, /oauth/userinfo and stubs, so both
instance APIs now say 4.5.0 (compatible; PrivaPub <release>). The pasture gets a push sink (pushsink.test) and
scenarios/push.sh in batch A.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-18 09:18:26 +02:00
1 parent 87529bca49
commit 9b792042e6
43 files changed
+2079 -49

No files matched your search

@@ -154,6 +154,7 @@ namespace PrivaPub.Domain.Moderation
// nothing acts as it from now on: its Mastodon tokens end and its open streams close
await _tokens.RevokeBySubjectAsync(avatar.ID, CancellationToken.None);
await _authorizations.RevokeBySubjectAsync(avatar.ID, CancellationToken.None);
await Social.WebPush.Forget(new[] { avatar.ID });//and its Web Push subscriptions with them (R9): it is pushed nothing
Streams.CloseFor(avatar.ID);
}
if (action is ModerationActions.Silence or ModerationActions.Suspend)
+21
View File
@@ -7,6 +7,25 @@ namespace PrivaPub.Domain.Social
{
public static class Notifications
{
// told of every notification once it is kept for the persona's lists and streams: never one the notification policy
// filtered or dropped, nor one its blocks and mutes hid. Web Push hangs here (R9, WebPushDispatcher, set while the
// host runs; the command line sets nothing). Whatever it does never fails the notification.
public static Func<Notification, CancellationToken, Task> Told { get; set; }
static async Task Tell(Notification notification, CancellationToken token)
{
if (Told is not { } told)
return;
try
{
await told(notification, token);
}
catch (Exception)
{
//the hook logs its own failures
}
}
public static async Task Add(string avatarId, NotificationType type, string fromAccountId, string fromActorUri, string postId,
CancellationToken token, string emoji = default, string emojiUrl = default, string text = default)
{
@@ -42,6 +61,7 @@ namespace PrivaPub.Domain.Social
var told = new Timelines.Streams.Event("notification", notification.ID);
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.User), told);
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.UserNotification), told);
await Tell(notification, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
@@ -73,6 +93,7 @@ namespace PrivaPub.Domain.Social
var told = new Timelines.Streams.Event("notification", notification.ID);
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.User), told);
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.UserNotification), told);
await Tell(notification, CancellationToken.None);
}
}
}
+156
View File
@@ -0,0 +1,156 @@
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Controllers;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Social;
using System.Text.Json;
namespace PrivaPub.Domain.Social
{
public sealed record PushPayload(string SubscriptionId, string NotificationId);
// What Web Push sends, and to whom (completion program R9). A notification is pushed to each subscription of its persona
// whose alert for its type is on and whose policy admits its sender, as Mastodon's Web::PushSubscription#pushable? says;
// a filtered one never is, nor anything for a persona held back (suspended, or its login banned).
public static class WebPush
{
// Mastodon 4.5's alerts (its Notification::TYPES, which data[alerts] takes), then Pleroma's for emoji reactions.
// admin.sign_up and admin.report are kept but never fire: moderation is on /clientapi, and it notifies nobody.
public static readonly IReadOnlyList<string> Alerts = new[]
{
"mention", "quote", "status", "reblog", "follow", "follow_request", "favourite", "poll", "update", "quoted_update",
"severed_relationships", "moderation_warning", "annual_report", "admin.sign_up", "admin.report", "pleroma:emoji_reaction"
};
/// <summary>The alert a notification's type answers to: its Mastodon name.</summary>
public static string AlertOf(NotificationType type) => NotificationsController.Names.TryGetValue(type, out var name) ? name : default;
public static string Name(PushPolicy policy) => policy switch
{
PushPolicy.Followed => "followed",
PushPolicy.Follower => "follower",
PushPolicy.None => "none",
_ => "all"
};
public static PushPolicy? ParsePolicy(string value) => value?.Trim().ToLowerInvariant() switch
{
"all" => PushPolicy.All,
"followed" => PushPolicy.Followed,
"follower" => PushPolicy.Follower,
"none" => PushPolicy.None,
_ => (PushPolicy?)null
};
public static async Task<bool> Pushable(PushSubscription subscription, Notification notification, CancellationToken token)
{
if (notification.Filtered || notification.AvatarId != subscription.AvatarId)
return false;
var alert = AlertOf(notification.Type);
if (alert == default || !subscription.Alerts.Contains(alert))
return false;
return await Admits(subscription, notification, token);
}
// Mastodon's policy_allows_notification?: everyone, nobody, the accounts the persona follows, those that follow it. What
// the persona tells itself (its own poll ended, a warning from the staff, which Mastodon sends from the account itself)
// passes every policy but none.
static async Task<bool> Admits(PushSubscription subscription, Notification notification, CancellationToken token)
{
if (subscription.Policy == PushPolicy.None)
return false;
if (subscription.Policy == PushPolicy.All || notification.FromAccountId == subscription.AvatarId)
return true;
var avatarId = subscription.AvatarId;
var fromId = notification.FromAccountId;
var fromUri = notification.FromActorURI;
if (subscription.Policy == PushPolicy.Followed)
{
if (!string.IsNullOrEmpty(fromId) && await DB.Default.Find<Following>()
.Match(f => f.AvatarId == avatarId && f.FollowerKind == null && f.State == FollowState.Accepted && f.TargetAccountId == fromId).ExecuteAnyAsync(token))
return true;
return !string.IsNullOrEmpty(fromUri) && await DB.Default.Find<Following>()
.Match(f => f.AvatarId == avatarId && f.FollowerKind == null && f.State == FollowState.Accepted && f.TargetActorURI == fromUri).ExecuteAnyAsync(token);
}
if (!string.IsNullOrEmpty(fromUri) && await DB.Default.Find<Follower>()
.Match(f => f.LocalActorId == avatarId && f.LocalActorKind == LocalActorKind.Person && f.IsAccepted && f.ActorURI == fromUri).ExecuteAnyAsync(token))
return true;
return !string.IsNullOrEmpty(fromId) && await DB.Default.Find<Following>()
.Match(f => f.AvatarId == fromId && f.FollowerKind == null && f.State == FollowState.Accepted && f.TargetAccountId == avatarId).ExecuteAnyAsync(token);
}
/// <summary>Every subscription of these personas goes (their tokens ended, or they did).</summary>
public static async Task Forget(IReadOnlyCollection<string> avatarIds)
{
var ids = avatarIds?.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList() ?? new List<string>();
if (ids.Count > 0)
await DB.Default.DeleteAsync<PushSubscription>(s => ids.Contains(s.AvatarId));//a delete takes no token outside a transaction
}
/// <summary>The subscription made with this token goes (the token was revoked).</summary>
public static async Task ForgetToken(string tokenId)
{
if (!string.IsNullOrEmpty(tokenId))
await DB.Default.DeleteAsync<PushSubscription>(s => s.TokenId == tokenId);
}
}
// Hangs on Notifications.Told while the host runs and queues one SendPush job per subscription the notification goes to
// (WebPushJob sends it, encrypted). Queueing is a few indexed reads and inserts; nothing leaves the server here.
public class WebPushDispatcher : IHostedService
{
readonly IJobQueue _jobs;
readonly ILogger<WebPushDispatcher> _logger;
public WebPushDispatcher(IJobQueue jobs, ILogger<WebPushDispatcher> logger)
{
_jobs = jobs;
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
Notifications.Told = Dispatch;
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
Notifications.Told = default;
return Task.CompletedTask;
}
public async Task Dispatch(Notification notification, CancellationToken token)
{
try
{
if (notification == default || notification.Filtered || Moderation.Restrictions.IsSuspended(notification.AvatarId))
return;
var alert = WebPush.AlertOf(notification.Type);
if (alert == default)
return;
var avatarId = notification.AvatarId;
var subscriptions = await DB.Default.Find<PushSubscription>().Match(s => s.AvatarId == avatarId && s.Alerts.Contains(alert)).ExecuteAsync(token);
var jobs = new List<Job>();
foreach (var subscription in subscriptions)
if (await WebPush.Pushable(subscription, notification, token))
jobs.Add(new Job
{
Kind = JobKind.SendPush,
Payload = JsonSerializer.Serialize(new PushPayload(subscription.ID, notification.ID)),
Host = Uri.TryCreate(subscription.Endpoint, UriKind.Absolute, out var endpoint) ? endpoint.Host : default,
DedupeKey = $"push|{subscription.ID}|{notification.ID}"
});
if (jobs.Count > 0)
await _jobs.EnqueueMany(jobs, token);
}
catch (Exception ex) when (ex is not OperationCanceledException || !token.IsCancellationRequested)
{
_logger.LogWarning(ex, "{Service} could not queue the pushes of a notification", nameof(WebPushDispatcher));
}
}
}
}
+29 -3
View File
@@ -21,14 +21,40 @@ namespace PrivaPub.Domain.Statuses
await Read(authorAvatarId, conversationId, postId, token);
}
public static Task Read(string avatarId, string conversationId, string postId, CancellationToken token) =>
Mark(avatarId, conversationId, s => s.LastReadPostId, postId, token);
public static async Task Read(string avatarId, string conversationId, string postId, CancellationToken token)
{
await Mark(avatarId, conversationId, s => s.LastReadPostId, postId, token);
await DB.Default.Update<ConversationState>()
.Match(s => s.AvatarId == avatarId && s.ConversationId == conversationId && s.MarkedUnread)
.Modify(s => s.MarkedUnread, false)
.ExecuteAsync(token);
}
// Mastodon's unread: the conversation shows unread, whoever wrote last, until the persona reads it again (R9)
public static async Task Unread(string avatarId, string conversationId, CancellationToken token)
{
Task Write() => DB.Default.Update<ConversationState>()
.Match(s => s.AvatarId == avatarId && s.ConversationId == conversationId)
.Modify(s => s.MarkedUnread, true)
.Modify(s => s.UpdatedAt, DateTime.UtcNow)
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
try
{
await Write();
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
await Write();
}
}
public static Task Hide(string avatarId, string conversationId, string postId, CancellationToken token) =>
Mark(avatarId, conversationId, s => s.HiddenUpTo, postId, token);
public static bool IsUnread(ConversationState state, string lastPostId, string lastAuthorId, string avatarId) =>
lastPostId != default && lastAuthorId != avatarId && string.CompareOrdinal(lastPostId, state?.LastReadPostId) > 0;
state?.MarkedUnread == true
|| lastPostId != default && lastAuthorId != avatarId && string.CompareOrdinal(lastPostId, state?.LastReadPostId) > 0;
public static bool IsHidden(ConversationState state, string lastPostId) =>
state?.HiddenUpTo != default && string.CompareOrdinal(lastPostId, state.HiddenUpTo) <= 0;