R9 Web Push in decePub: one subscription per persona, each with its own token and registration
Build / Build (push) Successful in 56s

Settings gets "Notifications on this device" (PushEditor): personas, alerts in four groups, policy, a test
notification, after a one-time notice of what the push service learns. Each persona ticked gets its own service
worker registration (push-worker.js at push/{slot}/), so its own endpoint and keys, subscribed with its own token
(IHttpService's *As); nothing PrivaPub stores ties two personas together and the worker never reads the payload's
token. PushService follows the tokens (re-subscribing after a renewal), reconciles at every session start, turns
off before sign-out and drops deleted or suspended personas. A click opens that persona's notifications; the app
badge counts the unread ones. Exchanges now ask for the push scope.

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:50:46 +02:00
1 parent d33697e3fc
commit ab17959ed4
29 files changed
+2047 -67

No files matched your search

+96 -27
View File
@@ -20,7 +20,8 @@ namespace decePubClient.Services
/// Nothing throws: a failure comes back as a response carrying an invalid WebResult (SUtility.ClassifiedFailure, read
/// with ExtensionMethods.ReadWebResult), a cancelled request as HTTP 410. With retryOnError, 408/429/502/503/504 and
/// transport errors are retried after 1, 2, 4, 8, 16 and 29 seconds, then every 60, plus jitter, for at most 10
/// minutes. Every request PrivaPub answers is recorded in RequestStats.
/// minutes. Every request PrivaPub answers is recorded in RequestStats. The *As methods send a Mastodon API request as
/// a given persona of the login, whichever the session acts as: with that persona's own token, never another's.
/// </summary>
public interface IHttpService
{
@@ -48,6 +49,22 @@ namespace decePubClient.Services
/// current. A failure comes back like any other.
/// </summary>
Task<HttpResponseMessage> ExchangePersonaToken(string personaId, CancellationToken cancellationToken = default);
/// <summary>A Mastodon API GET as that persona (its token exchanged when missing, without making it current).</summary>
Task<HttpResponseMessage> GetAs(string personaId, string uri, string[] queryParams = default, bool retryOnError = false,
CancellationToken cancellationToken = default);
/// <summary>A Mastodon API POST as that persona (its token exchanged when missing, without making it current).</summary>
Task<HttpResponseMessage> PostAs(string personaId, string uri, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default);
/// <summary>A Mastodon API PUT as that persona (its token exchanged when missing, without making it current).</summary>
Task<HttpResponseMessage> PutAs(string personaId, string uri, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default);
/// <summary>A Mastodon API DELETE as that persona (its token exchanged when missing, without making it current).</summary>
Task<HttpResponseMessage> DeleteAs(string personaId, string uri, string[] queryParams = default, bool retryOnError = false,
CancellationToken cancellationToken = default);
}
public class HttpService(IHttpClientFactory httpClientFactory, TokenAuthStateProvider authStateProvider, AppStatusService statusService,
@@ -91,10 +108,40 @@ namespace decePubClient.Services
Send(nameof(Delete), () => BuildRequest(HttpMethod.Delete, uri, payload, queryParams), SendAuthRequest, retryOnError, cancellationToken);
public async Task<HttpResponseMessage> ExchangePersonaToken(string personaId, CancellationToken cancellationToken = default)
{
var (response, token) = await RequestPersonaToken(personaId, cancellationToken);
if (token is not null)
await authStateProvider.SetPersona(personaId, token, cancellationToken);
return response;
}
public Task<HttpResponseMessage> GetAs(string personaId, string uri, string[] queryParams = default, bool retryOnError = false,
CancellationToken cancellationToken = default) =>
Send(nameof(GetAs), () => BuildRequest(HttpMethod.Get, uri, queryParams: queryParams), (factory, ct) => SendAsGivenPersona(personaId, factory, ct),
retryOnError, cancellationToken);
public Task<HttpResponseMessage> PostAs(string personaId, string uri, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default) =>
Send(nameof(PostAs), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), (factory, ct) => SendAsGivenPersona(personaId, factory, ct),
retryOnError, cancellationToken);
public Task<HttpResponseMessage> PutAs(string personaId, string uri, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default) =>
Send(nameof(PutAs), () => BuildRequest(HttpMethod.Put, uri, payload, forceContent: true), (factory, ct) => SendAsGivenPersona(personaId, factory, ct),
retryOnError, cancellationToken);
public Task<HttpResponseMessage> DeleteAs(string personaId, string uri, string[] queryParams = default, bool retryOnError = false,
CancellationToken cancellationToken = default) =>
Send(nameof(DeleteAs), () => BuildRequest(HttpMethod.Delete, uri, queryParams: queryParams), (factory, ct) => SendAsGivenPersona(personaId, factory, ct),
retryOnError, cancellationToken);
/// <summary>The exchange itself (RFC 8693 on /oauth/token, PrivaPub's PersonaExchange): the response, and the persona's
/// new token when PrivaPub gave one. Nothing is stored here.</summary>
async Task<(HttpResponseMessage Response, string Token)> RequestPersonaToken(string personaId, CancellationToken cancellationToken)
{
var jwt = await authStateProvider.GetToken(cancellationToken);
if (jwt is null)
return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
return (SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]), null);
var response = await PostTotallyAnon(APIs.OAuth.POST_Token, new Dictionary<string, string>
{
@@ -106,14 +153,16 @@ namespace decePubClient.Services
["scope"] = APIs.OAuth.Scopes
}, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return response;
return (response, null);
var token = await response.Content.ReadFromJsonAsync<TokenResponse>(SUtility.MastodonSerializer, cancellationToken);
if (string.IsNullOrEmpty(token?.AccessToken))
return SUtility.ClassifiedFailure(FailureCodes.ServerError, localizer["The server sent an invalid response."], HttpStatusCode.BadGateway);
{
response.Dispose();
return (SUtility.ClassifiedFailure(FailureCodes.ServerError, localizer["The server sent an invalid response."], HttpStatusCode.BadGateway), null);
}
await authStateProvider.SetPersona(personaId, token.AccessToken, cancellationToken);
return response;
return (response, token.AccessToken);
}
async Task<HttpResponseMessage> Send(string caller, Func<HttpRequestMessage> requestFactory,
@@ -217,7 +266,7 @@ namespace decePubClient.Services
{
var request = requestFactory();
if (IsMastodon(request))
return await SendAsPersona(request, requestFactory, cancellationToken);
return await SendAsPersona((await authStateProvider.GetAuthData(cancellationToken)).PersonaId, request, requestFactory, cancellationToken);
var token = await authStateProvider.GetToken(cancellationToken);
if (token is null)
@@ -236,24 +285,39 @@ namespace decePubClient.Services
return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
}
/// <summary>
/// A Mastodon API request as the current persona. A missing token is exchanged first; a refused one (revoked,
/// or PrivaPub's database was reset) is exchanged once more and the request sent again. Without a usable JWT the
/// session ends. A persona PrivaPub refuses a token to while the JWT is good (suspended by the staff, or gone) is
/// marked refused (TokenAuthStateProvider.Refuse, which tells CascadingState once) and no request acts as it again.
/// </summary>
async Task<HttpResponseMessage> SendAsPersona(HttpRequestMessage request, Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken)
/// <summary>A Mastodon API request as the persona named, whichever the session acts as.</summary>
async Task<HttpResponseMessage> SendAsGivenPersona(string personaId, Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken)
{
var session = await authStateProvider.GetAuthData(cancellationToken);
if (authStateProvider.IsRefused(session.PersonaId))
var request = requestFactory();
if (!IsMastodon(request) || personaId is null)
{
request.Dispose();
return SUtility.ClassifiedFailure(FailureCodes.ClientError,
localizer["The request could not be prepared. Reload the page and try again."], HttpStatusCode.BadRequest);
}
return await SendAsPersona(personaId, request, requestFactory, cancellationToken);
}
/// <summary>
/// A Mastodon API request as the persona (the current one, or the one an *As method names), with that persona's own
/// token. A missing token is exchanged first; a refused one (revoked, or PrivaPub's database was reset) is exchanged
/// once more and the request sent again. Without a usable JWT the session ends. A persona PrivaPub refuses a token to
/// while the JWT is good (suspended by the staff, or gone) is marked refused (TokenAuthStateProvider.Refuse, which
/// tells CascadingState once) and no request acts as it again.
/// </summary>
async Task<HttpResponseMessage> SendAsPersona(string personaId, HttpRequestMessage request, Func<HttpRequestMessage> requestFactory,
CancellationToken cancellationToken)
{
if (authStateProvider.IsRefused(personaId))
return PersonaRefused();
var token = await authStateProvider.GetPersonaToken(cancellationToken);
var token = await authStateProvider.GetPersonaToken(personaId, cancellationToken);
if (token is null)
{
var exchanged = await Exchange(session.PersonaId, cancellationToken);
var exchanged = await Exchange(personaId, cancellationToken);
if (exchanged.Refused)
return PersonaRefused(session.PersonaId);
return PersonaRefused(personaId);
token = exchanged.Token;
}
if (token is null)
@@ -265,10 +329,10 @@ namespace decePubClient.Services
return response;
response.Dispose();
await authStateProvider.ForgetPersonaToken(session.PersonaId, cancellationToken);
var renewed = await Exchange(session.PersonaId, cancellationToken);
await authStateProvider.ForgetPersonaToken(personaId, cancellationToken);
var renewed = await Exchange(personaId, cancellationToken);
if (renewed.Refused)
return PersonaRefused(session.PersonaId);
return PersonaRefused(personaId);
token = renewed.Token;
if (token is null)
return await EndSession(cancellationToken);
@@ -283,15 +347,20 @@ namespace decePubClient.Services
return await EndSession(cancellationToken);
}
/// <summary>A new token for the persona, or none; Refused when PrivaPub itself answered no (its 400 invalid_grant or a
/// 403) to a JWT the client still holds as good, rather than the request failing on the way.</summary>
/// <summary>A new token for the persona, kept without making it current, or none; Refused when PrivaPub itself answered
/// no (its 400 invalid_grant or a 403) to a JWT the client still holds as good, rather than the request failing on the
/// way.</summary>
async Task<(string Token, bool Refused)> Exchange(string personaId, CancellationToken cancellationToken)
{
if (personaId is null)
return (null, false);
using var response = await ExchangePersonaToken(personaId, cancellationToken);
if (response.IsSuccessStatusCode)
return (await authStateProvider.GetPersonaToken(cancellationToken), false);
var (exchange, token) = await RequestPersonaToken(personaId, cancellationToken);
using var response = exchange;
if (token is not null)
{
await authStateProvider.SetPersonaToken(personaId, token, cancellationToken);
return (token, false);
}
var refused = response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden
&& !response.Content.Headers.Contains(SUtility.FailureHeader)