diff --git a/.gitignore b/.gitignore
index 4479eae..9024341 100644
--- a/.gitignore
+++ b/.gitignore
@@ -407,6 +407,7 @@ PrivaPub/media-store-incoming/
PrivaPub/media-store-backups/
tools/pasture/.publish/
tools/pasture/.flood/
+tools/pasture/.pushsink/
.claude/worktrees/
tools/pasture/.ca/
tools/pasture/.state/
diff --git a/CLAUDE.md b/CLAUDE.md
index 4937e80..1c7e09f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -94,6 +94,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Infrastructure/
Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out
Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker
+ Push/ Web Push (R9): Vapid (the server's key, ES256 JWTs), WebPushCrypto (aes128gcm, aesgcm),
+ WebPushJob (SendPush, through the `push` client)
Ids/ PrivacyIds (day-only ids for personas and groups, arrival-ordered ids for remote posts)
Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs
Cli/ AdminCommands (`PrivaPub admin promote|demote|role|create-root|move-persona|delete-root|reset-totp`,
@@ -130,6 +132,7 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Moderation/ ModerationLog (the staff's log, AdminAction), AccountModeration (silence, suspend, posts
removed), Restrictions (who is held back, in memory), ModerationFlags (posts' flags again)
Social/ FollowService (local in-process, remote Follow/Accept), Notifications, Announcements,
+ WebPush (who is pushed what; WebPushDispatcher on Notifications.Told),
FeaturedTags (a persona's featured hashtags, sent and received), Suggestions (who to follow)
Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService
Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
@@ -146,8 +149,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Entities/ Mappers/ Mastodon entities; MastodonMapper (Account, Status), AccountSearch
Controllers/ apps, instance, accounts, statuses, timelines, notifications, search, lists, filters,
media, polls, reactions, reports, scheduled statuses, streaming, tags, trends, cdns,
- provenance, custom emojis, featured tags, suggestions; the stubs are `StubsController`
- in `SearchController.cs`
+ provenance, custom emojis, featured tags, suggestions, Web Push (`PushController`), OAuth
+ userinfo; the stubs are `StubsController` in `SearchController.cs`
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, RootRemoval,
@@ -429,6 +432,87 @@ client's role route never does.
and `ProtectiveMerge` deletes those of a persona deleted since. Mastodon's account archive has no featured tags, notes or
endorsements, so the persona archive leaves them out.
+## Web Push (completion program R9)
+
+- **The key** (`Models/PushKeys.cs`, `Infrastructure/Push/Vapid.cs`): one P-256 VAPID key pair (RFC 8292), made the first
+ time something needs it and kept in Mongo (one document, id `vapid`; two processes at once keep the first one saved).
+ Its public key, the uncompressed point in URL-safe base64 with its padding (as Mastodon writes `VAPID_PUBLIC_KEY`), is
+ v2's `configuration.vapid.public_key`, an app's `vapid_key` (`POST /api/v1/apps`, `apps/verify_credentials`) and every
+ subscription's `server_key`.
+- **Subscriptions** (`Models/Social/PushSubscription.cs`, a new collection, unique on `TokenId`): one per Mastodon access
+ token, as on Mastodon, so one persona's (the token's subject) and never its login's. Each holds the endpoint, the
+ client's `p256dh` and `auth` as it sent them, `standard`, the alerts turned on, the policy, and the token itself, which
+ the payload carries back. The alerts are Mastodon 4.5's names (`WebPush.Alerts`: `mention`, `quote`, `status`,
+ `reblog`, `follow`, `follow_request`, `favourite`, `poll`, `update`, `quoted_update`, `severed_relationships`,
+ `moderation_warning`, `annual_report`, `admin.sign_up`, `admin.report`) and Pleroma's `pleroma:emoji_reaction`; those
+ PrivaPub never notifies (the admin ones, `quoted_update`, `severed_relationships`, `annual_report`) are kept and never fire.
+- **Routes** (`Api/Mastodon/Controllers/PushController.cs`, the `push` scope, a persona's token; Mastodon's parameters as
+ a form or JSON):
+ - `POST /api/v1/push/subscription`: `subscription[endpoint]` (an https URL; plain http only where
+ `Federation:AllowPlainHttp`, which production refuses), `subscription[keys][p256dh]` (a point on P-256),
+ `subscription[keys][auth]` (16 bytes), `subscription[standard]`, `data[alerts][]`, `data[policy]` (`all` by
+ default, `followed`, `follower`, `none`). It replaces the token's subscription and answers it; 400 without an
+ endpoint, 422 for an endpoint that is no https URL, keys that are no Web Push keys, or another policy.
+ - `GET`: the token's subscription, or 404 `Record not found`.
+ - `PUT`: `data[alerts]` and `data[policy]` replace what it had, as Mastodon's update does (an alert left out is off, a
+ policy left out is `all`); 404 without one.
+ - `DELETE`: `{}`, whether there was one or not.
+ - The answer is Mastodon's `WebPushSubscription`: `id` (a string), `endpoint`, `standard`, `alerts` (every alert, true
+ or false), `server_key`, `policy`.
+- **What is pushed** (`Domain/Social/WebPush.cs`): `Notifications.Add` and `Notifications.Warn` tell `Notifications.Told`
+ of each notification they keep, never one the notification policy filtered or dropped nor one blocks or mutes hid.
+ `WebPushDispatcher` (a hosted service: the command line pushes nothing) hangs there and queues one `SendPush` job
+ (`JobKind.SendPush`, the last member; dedupe `push||`, its host the endpoint's) for each
+ subscription of that persona whose alert for the type is on and whose policy admits the sender: `followed`, an account
+ the persona follows; `follower`, one that follows it; the persona itself (its poll ended, the staff's warning) under
+ every policy but `none`. Nothing for a persona `Restrictions` holds back (suspended, its login banned).
+- **Sending** (`Infrastructure/Push/WebPushJob.cs`). Everything is read again when the job runs: the subscription, its
+ persona (not held back, not deleted), its token (valid, unexpired, its subject the subscription's persona, or the
+ subscription goes), the notification (still the persona's, still pushable, still shown to it by
+ `MastodonMapper.Notifications`). The payload is Mastodon's JSON, at most 3993 bytes (`WebPushCrypto.MaxPayload`):
+ - `access_token`, the subscription's own token, never another's; `preferred_locale`, the persona's default posting
+ language, else the server's first; `notification_id`, PrivaPub's id (a string); `notification_type`, its Mastodon
+ name;
+ - `icon`, the sender's avatar through the media proxy; `title`, Mastodon's English subjects ("You were mentioned by
+ …", "… favorited your post", …); `body`, the post's warning, else its words, else the sender's bio, without tags, 140
+ characters at most.
+ - **Encryption** (`WebPushCrypto`, System.Security.Cryptography only: ECDiffieHellman on P-256, HKDF-SHA-256, AesGcm),
+ a fresh key pair and salt per message. A `standard` subscription gets RFC 8291's `aes128gcm` (RFC 8188's header:
+ salt, record size 4096, our public key as the key id; one record, delimiter 0x02) with `Authorization: vapid
+ t=, k=`. Any other gets the draft Mastodon still sends by default, `aesgcm` (two bytes of padding length
+ first) with `Encryption: salt=…`, `Crypto-Key: dh=;p256ecdsa=` and `Authorization: WebPush
+ `. Both carry `Content-Type: application/octet-stream`, `TTL: 172800` and `Urgency: normal`, as Mastodon's do.
+ - **The JWT** (`VapidKey.Token`): ES256, `aud` the endpoint's origin, `exp` twelve hours ahead (RFC 8292 allows a
+ day), `sub` `mailto:` the contact email the administrator gave, else the server's address (`mailto:privapub@`
+ for a test server on plain http). Never a persona.
+ - **Answers:** 2xx is done; 404 and 410 delete the subscription; 408, 429, a 5xx, a timeout or a broken connection retry
+ on the jobs' backoff (six attempts, about forty minutes); any other answer, a redirect, or an endpoint that does not
+ resolve to a public address drops the push, logged by the push service's host alone. Four run at a time, four per host.
+- **decePub:** the first-party client's exchanged persona tokens may hold `push` too (`PersonaExchange.FirstPartyScopes`),
+ so it can subscribe like any other client.
+- **Lifecycle:** a subscription goes with its token: `/oauth/revoke` (an OpenIddict handler after the revocation),
+ `RootSessions.Revoke` (a password changed or recovered, two-factor turned on, signing out everywhere, a ban, a deleted
+ login), `PersonaRemoval`, a suspension (`AccountModeration`), `admin move-persona`, and any token found unusable when a
+ push is sent. A new subscription made with the same token replaces it.
+- **Backups:** `PushSubscription` and `PushKeys` are never in a backup (`ServerBackup.Excluded`). A restore leaves the
+ live key as it is, so the key the push services know never changes, and deletes every subscription with the tokens
+ (`ProtectiveMerge`); clients subscribe again once signed in.
+- **The rest of the honest version** (`docs/MASTODON-API.md`, the walk of every Mastodon 4.3, 4.4 and 4.5 client route),
+ built with it:
+ - `POST /api/v1/conversations/:id/unread` (`ConversationState.MarkedUnread`, until it is read);
+ - `GET /api/v1/domain_blocks/preview` (`following_count` and `followers_count` the block would take, counted as
+ `BlockDomain` takes them);
+ - `POST /api/v2/notifications/clear` and `PUT /api/v2/notifications/policy`;
+ - `GET`/`POST /oauth/userinfo` (the `profile` scope: `iss`, `sub` the actor, `name`, `preferred_username`, `profile`,
+ `picture`; `userinfo_endpoint` in the server's metadata) and `verify_credentials` for a `profile` token
+ (`Scope("read:accounts|profile")`: a scope written `a|b` is either);
+ - stubs: `GET /api/v1/annual_reports/:year` (none) and `POST …/:year/read` (`{}`), `POST /api/v1/accounts` (403, as
+ Mastodon with registrations closed: they are cut), `POST /api/v1/emails/confirmations` (403) and
+ `GET /api/v1/emails/check_confirmation` (`true`), `GET /api/v1/peers/search` (`[]`), and 4.4's
+ `GET /api/v1_alpha/async_refreshes/:id` (404: PrivaPub never sends `Mastodon-Async-Refresh`).
+
+ Both instance APIs then say `4.5.0 (compatible; PrivaPub )` (Mastodon invariant 6).
+
## Custom emoji (completion program R7)
- **The server's own emoji** (`Models/Post/LocalEmoji.cs`, a collection of its own): a shortcode (`[a-zA-Z0-9_]{2,64}`,
@@ -487,8 +571,12 @@ client's role route never does.
1. **Every outbound request goes through `IFederationHttp`.** Its handler resolves the name itself and connects only
to public addresses; redirects are followed by hand (three at most, each re-checked); bodies are capped at 1 MB;
only JSON media types are read; a refused URL is not asked again for five minutes. Never create another
- `HttpClient` for federation. The only other outbound traffic is SMTP and `GeoUpdater`'s monthly DB-IP Lite download
- (its own `geo` client, a fixed HTTPS host, size-capped, the file checked before it is swapped in).
+ `HttpClient` for federation. The only other outbound traffic is SMTP, `GeoUpdater`'s monthly DB-IP Lite download
+ (its own `geo` client, a fixed HTTPS host, size-capped, the file checked before it is swapped in), and Web Push (R9):
+ the named `push` client, on `SafeHttpHandlerFactory`'s handler (it resolves the name itself and connects only to
+ public addresses, with federation's test-network exceptions; no redirects, cookies or proxy), https only (plain http
+ only where `Federation:AllowPlainHttp`), 5 s to connect and 15 s a request. It POSTs encrypted payloads to the
+ endpoints clients subscribed with, and reads nothing back but the status.
2. **Every fetch is signed by the instance actor** (`privapub`), never by a persona; deliveries are signed by the acting
avatar or group. Both are draft-cavage rsa-sha256 over `(request-target) host date` (+ `digest` on bodies). Inbound,
an RFC 9421 signature (`Signature-Input`) is verified too, its target against our public address. A persona's own
@@ -605,9 +693,14 @@ client's role route never does.
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade. Any `/api`
error that leaves without a body (a challenge, a 404 from routing, a 429) gets Mastodon's `{"error": ...}` from
`UseMastodonErrorBodies`.
-6. Advertise `4.2.0 (compatible; PrivaPub)` until streaming and Web Push exist too; `api_versions.mastodon = 7` stays,
- because clients show quotes from it, so everything a 4.3+ client calls because of it must answer: grouped
- notifications (`/api/v2/notifications`, its unread count, groups, accounts and dismiss) and the notification policy.
+6. **Advertise `4.5.0 (compatible; PrivaPub )`** in both instance APIs (`InstanceController.Version`, since R9;
+ the release is the build's tag without its `v`, else its ref or commit), with `api_versions.mastodon = 7`, 4.5's own.
+ The number is the walk's (`docs/MASTODON-API.md`): every client route of Mastodon 4.3, 4.4 and 4.5 is served or
+ answers as a named stub, and none a client relies on is missing. A route Mastodon adds, or one PrivaPub stops serving,
+ goes there first, and the version follows the walk, never a wish; `tools/smoke/mastodon-api.sh` checks it on every
+ deploy. Clients branch on both numbers, so everything a 4.3+ client calls must answer: grouped notifications
+ (`/api/v2/notifications`, its unread count, groups, accounts, dismiss and clear), quotes, Web Push, streaming and the
+ notification policy.
The policy is real (`Domain/Social/NotificationPolicies.cs`, applied in `Notifications.Add`): accepted by default,
it filters (into a `NotificationRequest` per account, out of every list and count unless `include_filtered`) or drops
what strangers, accounts that do not follow the persona (or only for three days), accounts newer than 30 days,
@@ -624,6 +717,8 @@ client's role route never does.
private notes (shown to it alone, never federated), dismissed suggestions. Suggestions never offer the persona itself
or a sibling persona of its login, and `familiar_followers` answers no one for every account, since it would tell who
follows whom here.
+9. **A Web Push subscription is its token's** (R9): one per access token, read, changed and deleted only with that token,
+ pushed only its persona's notifications, and its payload carries only that token. It ends when the token does.
## Media invariants
@@ -766,6 +861,12 @@ client's role route never does.
decided in `docs/ROADMAP.md` ("Owner decisions on what PrivaPub reveals"). Anything new that tells another server
something about an avatar gets the same treatment: ask, then record it there.
- **Location-ranged posts never federate.**
+- **Web Push tells a push service when, never what** (R9). Every payload is encrypted to the subscribing client's own
+ keys (RFC 8291, or the aesgcm draft), so the push service (Google's, Mozilla's, Apple's, a UnifiedPush distributor)
+ learns only that this server has something for that endpoint, when, and how big: never the words, the sender or the
+ persona. The VAPID JWT names the push service's origin and the server's contact (the contact email, or the server's
+ address), never a persona. Subscriptions are per token, so per persona: none receives a sibling's notifications, and
+ nothing stored pairs two personas of one login.
- **Statistics name servers, never people** (owner decisions on statistics, 2026-10-03). Every interaction is recorded
through `IInteractionLedger` (`Infrastructure/Statistics`) as an `InteractionEvent` (90 days), but an event never
holds a root, persona or group id, an activity id, an inbox URL, a remote actor URI or a sender IP:
@@ -868,7 +969,8 @@ client's role route never does.
**Never in a backup** (`ServerBackup.Excluded`): `InteractionSalt` (owner rule), `Job` and `Delivery` (work in flight),
`EmailRecovery` and `EmailVerification`, `openiddict.tokens` and `.authorizations` (sessions), `MaintenanceLock`, `ServerSetup` (a restore
- never opens setup again), and `AppConfiguration` (its SMTP password; it is made again from appsettings at boot). One
+ never opens setup again), `AppConfiguration` (its SMTP password; it is made again from appsettings at boot),
+ `PushSubscription` (sessions' too, R9) and `PushKeys` (the VAPID key: a restore keeps the live one). One
backup or restore runs at a time
(`MaintenanceLock`, a heartbeat document; a holder silent for 2 minutes is taken over), and the media janitor purges
nothing meanwhile. `BackupScheduler` backs up at `Backups:NightlyAt` (03:30 UTC), or at once when it missed the night;
@@ -895,7 +997,8 @@ client's role route never does.
nothing holds any more goes there too); the staff's log (`AdminAction`) is the union, so a restore never loses an
entry, its own request included, and the server's settings and custom emoji (`LocalEmoji`, R7) are P's: the live
settings and emoji outlive a restore, and an emoji deleted since stays deleted;
- 5. every session ends (a new `SessionStamp` for each root, persona tokens deleted), circuits close, and a
+ 5. every session ends (a new `SessionStamp` for each root, persona tokens deleted, and every Web Push subscription with
+ them, R9: clients subscribe again, to the same VAPID key), circuits close, and a
`RestoreRecord` (never in a backup) tells what happened (`admin restore --status`).
Each attempt redoes everything. Refused before anything changed, it is abandoned and recorded, and the server boots as
@@ -1154,6 +1257,25 @@ tools/pasture/run.sh down # removes e
DM; her endorsement of him and her note on him are hers alone (feat_bob, a sibling persona of the same login, sees
neither, and `GET /api/v1/accounts/:id/endorsements` answers nobody else), and feat_bob is never suggested to her; the
stubs (translate 422, annual reports, the admin API 403) answer.
+- **Web Push (`scenarios/push.sh`, needs mastodon and pushsink; after featured in batch A):** push_alice subscribes with
+ the sink's keys; mastouser's mention reaches the sink as aes128gcm, its VAPID JWT by the instance API's key for the
+ sink's origin with TTL and Urgency, and decrypts to a `mention` with her own access token and her notification's id;
+ the mention alert turned off stops it while his favourite is pushed; `policy=followed` stops him until she follows him;
+ `standard=false` is aesgcm (Encryption, Crypto-Key, `WebPush`) and decrypts too, and the new subscription replaces the
+ old; push_bob, a sibling persona of the same login, never gets hers nor she his; DELETE stops the pushes; the sink
+ answering 410 removes the subscription; a token revoked at `/oauth/revoke` is pushed nothing; no subscription is left.
+ Both instance APIs say 4.5.0 and give the key an app registration gives.
+- **pushsink (`peers/pushsink.sh`, `pushsink/pushsink.cs`, as `pushsink.test`):** a Web Push service of the pasture's
+ own, a .NET file-based app published once into `.pushsink` like flood. It makes each subscription's keys itself, checks
+ what a push service checks (the VAPID JWT: ES256 by the server's key, the instance API's or the one the scenario gave
+ it; `aud` its own origin; `exp` ahead and at most a day; a `mailto:` or `https:` `sub`; TTL), decrypts `aes128gcm` and
+ `aesgcm` with its own code, not PrivaPub's, and keeps what it took. Its API, behind Caddy: `POST /subscriptions`
+ (`{"server_key"}` optional) answers `{id, endpoint, keys: {p256dh, auth}}`; `GET /subscriptions/{id}`;
+ `POST /subscriptions/{id}/answer` (`{"status": 410, "times": 1}`, for the next pushes); `GET` and `DELETE
+ /subscriptions/{id}/received` (each push: when, the status answered, `ok` and the errors, headers, the JWT's claims, the
+ decrypted payload); `POST /push/{id}` is the push service (201, 403 for a VAPID failure, 400 for the rest, 404 for no
+ such subscription); `/health`. PrivaPub reaches it through Caddy as every peer, since its `push` client honours
+ `appsettings.Pasture.json`'s Federation exceptions; nothing new is configured. In memory only.
- **Mailpit (1.31.4, `peers/mailpit.sh`, as `mailpit.test`):** the pasture's mail server. `appsettings.Pasture.json`
sends PrivaPub's mail to `pasture-mailpit:1025` (plain SMTP, any login accepted) and its recovery links to
`https://decepub.test`. `scenarios/accounts.sh` reads what arrived through Mailpit's API: an address set with the
@@ -1318,8 +1440,9 @@ the owner's GoToSocial account.**
the server's own pre-deploy backup (`PrivaPub admin backup --kind pre-deploy --db-only`, run from the new build before
its migrations, verified, and checked to hold no statistics salt; no deploy while `restore.json` waits),
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) and `tools/smoke/mastodon-api.sh` (app registration, client credentials,
- discovery, instance, public timeline). Then it checks that what production should be running is running:
+ Swagger 404, inbox junk 400, unsigned 401) and `tools/smoke/mastodon-api.sh` (app registration with its `vapid_key`,
+ client credentials, discovery, both instance APIs with the advertised version and the VAPID key, public timeline).
+ Then it checks that what production should be running is running:
- NodeInfo and the instance API agree on registrations (closed, invitations only);
- `/stargazing` says the crawler is on;
- nothing signs in (owner decision 2026-10-17): the deploy's own login, `deploy-smoke`, gave its @thepra to the
diff --git a/FEDERATION.md b/FEDERATION.md
index 559277d..d33b5ed 100644
--- a/FEDERATION.md
+++ b/FEDERATION.md
@@ -456,6 +456,14 @@ PrivaPub keeps statistics about servers, never about accounts (see `/stargazing`
- **Opting out:** in robots.txt, the crawler obeys the group `PrivaPub-Stargazer`, then `PrivaPub`, then `*`. A
robots.txt that answers with a server error or times out also keeps it out, as do this server's domain blocks.
+## Web Push
+
+Not federation, but traffic an operator may see: when a client signed in as a persona subscribes to Web Push (Mastodon's
+`/api/v1/push/subscription`), PrivaPub POSTs its notifications to the push service the client named, encrypted to the
+client (RFC 8291 `aes128gcm`, or the older `aesgcm` draft Mastodon still sends) and carrying a VAPID JWT (RFC 8292) signed
+by the server's own P-256 key, the one `/api/v2/instance` gives as `configuration.vapid.public_key`. No HTTP signature
+and no ActivityPub; https endpoints on public addresses only, no redirects. The push service learns when, never what.
+
## Local-only posts
Posts with a location (shown to nearby users of this server) never leave the server, in any form.
diff --git a/PrivaPub/Api/Mastodon/Auth/OAuthSetup.cs b/PrivaPub/Api/Mastodon/Auth/OAuthSetup.cs
index 935103d..ad122fb 100644
--- a/PrivaPub/Api/Mastodon/Auth/OAuthSetup.cs
+++ b/PrivaPub/Api/Mastodon/Auth/OAuthSetup.cs
@@ -72,6 +72,23 @@ namespace PrivaPub.Api.Mastodon.Auth
options.AddEventHandler(builder => builder.UseInlineHandler(OutOfBandCode)
.SetOrder(OpenIddictServerAspNetCoreHandlers.Authentication.ProcessFormPostResponse.Descriptor.Order - 1_000)
.SetType(OpenIddictServerHandlerType.Custom));
+ // a revoked token takes its Web Push subscription with it (R9), once OpenIddict has revoked it
+ options.AddEventHandler(builder => builder.UseInlineHandler(async context =>
+ {
+ if (context.GenericTokenPrincipal?.GetTokenId() is { Length: > 0 } tokenId)
+ await PrivaPub.Domain.Social.WebPush.ForgetToken(tokenId);
+ })
+ .SetOrder(OpenIddictServerHandlers.Revocation.RevokeToken.Descriptor.Order + 1_000)
+ .SetType(OpenIddictServerHandlerType.Custom));
+ // Mastodon 4.4's userinfo (Controllers/UserinfoController), named in the authorization server's metadata
+ options.AddEventHandler(builder => builder.UseInlineHandler(context =>
+ {
+ if (context.Issuer != default)
+ context.UserInfoEndpoint = new Uri(context.Issuer, Controllers.UserinfoController.Path.TrimStart('/'));
+ return default;
+ })
+ .SetOrder(OpenIddictServerHandlers.Discovery.AttachEndpoints.Descriptor.Order + 1_000)
+ .SetType(OpenIddictServerHandlerType.Custom));
options.AddEventHandler(builder => builder.UseInlineHandler(context =>
{
if (context.Response.AccessToken != default)
diff --git a/PrivaPub/Api/Mastodon/Auth/PersonaExchange.cs b/PrivaPub/Api/Mastodon/Auth/PersonaExchange.cs
index f5956ff..71c9163 100644
--- a/PrivaPub/Api/Mastodon/Auth/PersonaExchange.cs
+++ b/PrivaPub/Api/Mastodon/Auth/PersonaExchange.cs
@@ -27,6 +27,9 @@ namespace PrivaPub.Api.Mastodon.Auth
public static string FirstPartyClientId(IConfiguration configuration) =>
configuration["ClientApi:FirstPartyClientId"] is { Length: > 0 } clientId ? clientId : "decepub";
+ // what the first-party client's persona tokens may hold: push since R9, so decePub can subscribe to Web Push
+ public static readonly string[] FirstPartyScopes = { "read", "write", "follow", "push" };
+
//the first-party application: public (a browser keeps no secret), token exchange only, no redirect
public static async Task EnsureFirstPartyClient(IServiceProvider services, CancellationToken token)
{
@@ -46,7 +49,7 @@ namespace PrivaPub.Api.Mastodon.Auth
Permissions.GrantTypes.TokenExchange
}
};
- foreach (var scopeName in new[] { "read", "write", "follow" })
+ foreach (var scopeName in FirstPartyScopes)
descriptor.Permissions.Add(Permissions.Prefixes.Scope + scopeName);
var existing = await applications.FindByClientIdAsync(clientId, token);
diff --git a/PrivaPub/Api/Mastodon/Auth/TokenController.cs b/PrivaPub/Api/Mastodon/Auth/TokenController.cs
index 959704d..2cb74f4 100644
--- a/PrivaPub/Api/Mastodon/Auth/TokenController.cs
+++ b/PrivaPub/Api/Mastodon/Auth/TokenController.cs
@@ -70,7 +70,7 @@ namespace PrivaPub.Api.Mastodon.Auth
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
identity.SetClaim(Claims.Subject, avatar.ID);
identity.SetClaim(Claims.Name, avatar.UserName);
- identity.SetScopes(MastodonScopes.Parse(request.Scope).Intersect(new[] { "read", "write", "follow" }));
+ identity.SetScopes(MastodonScopes.Parse(request.Scope).Intersect(PersonaExchange.FirstPartyScopes));
identity.SetDestinations(_ => new[] { Destinations.AccessToken });
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
diff --git a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs
index 3e571f2..7e5fd8e 100644
--- a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs
+++ b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs
@@ -47,7 +47,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
_outbox = outbox;
}
- [HttpGet("/api/v1/accounts/verify_credentials"), Scope("read:accounts")]
+ // Mastodon 4.4: a token with the profile scope alone may read it too
+ [HttpGet("/api/v1/accounts/verify_credentials"), Scope("read:accounts|profile")]
public async Task VerifyCredentials(CancellationToken token) => Json(await _mapper.Local(Me, withSource: true, token));
[HttpPatch("/api/v1/accounts/update_credentials"), Scope("write:accounts"), EnableRateLimiting(RateLimiting.Uploads), RequestSizeLimit(20 * 1024 * 1024),
@@ -398,6 +399,28 @@ namespace PrivaPub.Api.Mastodon.Controllers
public async Task DomainBlocks(CancellationToken token) =>
Json((await DB.Default.Find().Match(b => b.AvatarId == MyId).Sort(b => b.Domain, Order.Ascending).ExecuteAsync(token)).Select(b => b.Domain).ToList());
+ // Mastodon's preview of a server block: how many of the accounts the persona follows, and of those following it, the
+ // block would take away, counted as BlockDomain takes them (an account on the server or a subdomain, or whose handle
+ // is)
+ [HttpGet("/api/v1/domain_blocks/preview"), Scope("write:blocks")]
+ public async Task PreviewDomainBlock(CancellationToken token)
+ {
+ var domain = Params.Get("domain")?.Trim().ToLowerInvariant();
+ if (string.IsNullOrEmpty(domain))
+ return Error(StatusCodes.Status400BadRequest, "Param is missing or the value is empty: domain");
+ if (Uri.CheckHostName(domain) != UriHostNameType.Dns)
+ return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Domain is invalid");
+ var followings = await _dbEntities.Followings.Match(f => f.AvatarId == MyId && !f.TargetIsLocal && f.State == FollowState.Accepted)
+ .ExecuteAsync(token);
+ var followers = await _dbEntities.Followers.Match(f => f.LocalActorId == MyId && f.IsAccepted).ExecuteAsync(token);
+ var handles = await Hidden.HandleDomains(followings.Select(f => f.TargetActorURI).Concat(followers.Select(f => f.ActorURI)).ToList(), token);
+ return Json(new
+ {
+ following_count = followings.Count(f => Hidden.Blocks(domain, f.TargetActorURI, handles.GetValueOrDefault(f.TargetActorURI))),
+ followers_count = followers.Count(f => Hidden.Blocks(domain, f.ActorURI, handles.GetValueOrDefault(f.ActorURI)))
+ });
+ }
+
[HttpPost("/api/v1/domain_blocks"), Scope("write:blocks")]
public async Task BlockDomain(CancellationToken token)
{
diff --git a/PrivaPub/Api/Mastodon/Controllers/AppsController.cs b/PrivaPub/Api/Mastodon/Controllers/AppsController.cs
index a323766..dee609f 100644
--- a/PrivaPub/Api/Mastodon/Controllers/AppsController.cs
+++ b/PrivaPub/Api/Mastodon/Controllers/AppsController.cs
@@ -16,10 +16,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
public class AppsController : MastodonController
{
readonly IOpenIddictApplicationManager _applications;
+ readonly PrivaPub.Infrastructure.Push.IVapid _vapid;
- public AppsController(IOpenIddictApplicationManager applications)
+ public AppsController(IOpenIddictApplicationManager applications, PrivaPub.Infrastructure.Push.IVapid vapid)
{
_applications = applications;
+ _vapid = vapid;
}
[HttpPost("/api/v1/apps"), AllowAnonymous]
@@ -69,7 +71,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
RedirectUris = redirects,
RedirectUri = string.Join("\n", redirects),
ClientId = descriptor.ClientId,
- ClientSecret = descriptor.ClientSecret
+ ClientSecret = descriptor.ClientSecret,
+ VapidKey = (await _vapid.Key(token)).ForApi//Web Push's server key, as Mastodon still gives it here
});
}
@@ -88,7 +91,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
website = properties.TryGetValue("website", out var website) ? website.GetString() : default,
scopes = User.GetScopes().ToList(),
redirect_uris = (await _applications.GetRedirectUrisAsync(application, token)).ToList(),
- vapid_key = string.Empty
+ vapid_key = (await _vapid.Key(token)).ForApi
});
}
diff --git a/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs b/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs
index 735349e..5093d30 100644
--- a/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs
+++ b/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs
@@ -28,7 +28,19 @@ namespace PrivaPub.Api.Mastodon.Controllers
// are enforced.
public class InstanceController : MastodonController
{
- public const string Version = "4.2.0 (compatible; PrivaPub)";
+ // The Mastodon version this API answers as, then PrivaPub's own release (its tag, or the commit a build came from).
+ // 4.5.0 since R9 (2026-10-18): every client route of Mastodon 4.3, 4.4 and 4.5 is served or answers as a documented
+ // stub (docs/MASTODON-API.md), streaming and Web Push included. api_versions.mastodon is 4.5's own, 7.
+ public const string MastodonVersion = "4.5.0";
+ public static readonly string Version = Advertised(string.IsNullOrEmpty(BuildInfo.Ref) ? BuildInfo.Commit : BuildInfo.Ref);
+
+ static string Advertised(string release)
+ {
+ var own = release?.Trim();
+ if (own is { Length: > 1 } && own[0] == 'v' && char.IsDigit(own[1]))
+ own = own[1..];//a tag v1.27.0 is release 1.27.0
+ return string.IsNullOrEmpty(own) ? $"{MastodonVersion} (compatible; PrivaPub)" : $"{MastodonVersion} (compatible; PrivaPub {own})";
+ }
const int ActivityWeeks = 12;
static readonly TimeSpan ActivityLifetime = TimeSpan.FromHours(1);
static volatile CachedActivity _activity;
@@ -39,14 +51,16 @@ namespace PrivaPub.Api.Mastodon.Controllers
readonly IOptionsMonitor _registrations;
readonly IInstanceSettings _settings;
readonly MastodonMapper _mapper;
+ readonly PrivaPub.Infrastructure.Push.IVapid _vapid;
public InstanceController(ILocalActorService localActors, IOptionsMonitor registrations, IInstanceSettings settings,
- MastodonMapper mapper)
+ MastodonMapper mapper, PrivaPub.Infrastructure.Push.IVapid vapid)
{
_localActors = localActors;
_registrations = registrations;
_settings = settings;
_mapper = mapper;
+ _vapid = vapid;
}
bool Open => _registrations.CurrentValue.IsOpen;
@@ -165,7 +179,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
statuses = Statuses,
media_attachments = MediaAttachments,
polls = Polls,
- translation = new { enabled = false }
+ translation = new { enabled = false },
+ vapid = new { public_key = (await _vapid.Key(token)).ForApi }//Web Push (R9): the key every push is signed with
},
registrations = new { enabled = Open, approval_required = false, message = Open ? default : RegistrationOptions.ClosedMessage },
contact = new { email = Settings.ContactEmail ?? string.Empty, account = await ContactAccount(Settings, token) },
diff --git a/PrivaPub/Api/Mastodon/Controllers/NotificationRequestsController.cs b/PrivaPub/Api/Mastodon/Controllers/NotificationRequestsController.cs
index 7cffccc..28f771c 100644
--- a/PrivaPub/Api/Mastodon/Controllers/NotificationRequestsController.cs
+++ b/PrivaPub/Api/Mastodon/Controllers/NotificationRequestsController.cs
@@ -69,7 +69,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v2/notifications/policy"), Scope("read:notifications")]
public async Task Policy(CancellationToken token) => Json(await V2(await NotificationPolicies.Of(MyId, token), token));
- [HttpPatch("/api/v2/notifications/policy"), Scope("write:notifications")]
+ [HttpPatch("/api/v2/notifications/policy"), HttpPut("/api/v2/notifications/policy"), Scope("write:notifications")]
public async Task SetPolicy(CancellationToken token)
{
var policy = await NotificationPolicies.Of(MyId, token);
diff --git a/PrivaPub/Api/Mastodon/Controllers/PushController.cs b/PrivaPub/Api/Mastodon/Controllers/PushController.cs
new file mode 100644
index 0000000..034edfa
--- /dev/null
+++ b/PrivaPub/Api/Mastodon/Controllers/PushController.cs
@@ -0,0 +1,169 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+
+using MongoDB.Driver;
+using MongoDB.Entities;
+
+using OpenIddict.Abstractions;
+using OpenIddict.Validation.AspNetCore;
+
+using PrivaPub.Api.Mastodon.Infrastructure;
+using PrivaPub.Domain.Social;
+using PrivaPub.Infrastructure.Http;
+using PrivaPub.Infrastructure.Push;
+using PrivaPub.Models.Social;
+
+namespace PrivaPub.Api.Mastodon.Controllers
+{
+ // Mastodon's Web Push API (completion program R9): one subscription per access token, so per persona, made, read, changed
+ // and deleted with the token itself (the `push` scope). Its parameters as Mastodon takes them, as a form or JSON:
+ // subscription[endpoint], subscription[keys][p256dh], subscription[keys][auth], subscription[standard] (4.4: RFC 8291
+ // and RFC 8292, else the aesgcm draft), data[alerts][] and data[policy] (all, followed, follower, none). Its answer is
+ // Mastodon's WebPushSubscription. What is pushed, and how, is WebPushDispatcher's and WebPushJob's.
+ public class PushController : MastodonController
+ {
+ const int MaxEndpointLength = 2048;
+
+ readonly IVapid _vapid;
+ readonly IOptionsMonitor _federation;
+
+ public PushController(IVapid vapid, IOptionsMonitor federation)
+ {
+ _vapid = vapid;
+ _federation = federation;
+ }
+
+ string TokenId => User.GetTokenId();
+
+ // a new subscription replaces the one this token had, as on Mastodon
+ [HttpPost("/api/v1/push/subscription"), Scope("push")]
+ public async Task Create(CancellationToken token)
+ {
+ var endpoint = Params.Get("subscription[endpoint]")?.Trim();
+ if (string.IsNullOrEmpty(endpoint))
+ return Error(StatusCodes.Status400BadRequest, "Param is missing or the value is empty: subscription");
+ if (endpoint.Length > MaxEndpointLength || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) || !WebPushJob.IsAllowed(uri, _federation.CurrentValue))
+ return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Endpoint is not a valid URL");
+ var p256dh = Params.Get("subscription[keys][p256dh]")?.Trim();
+ var auth = Params.Get("subscription[keys][auth]")?.Trim();
+ if (string.IsNullOrEmpty(p256dh) || string.IsNullOrEmpty(auth))
+ return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Key p256dh can't be blank, Key auth can't be blank");
+ if (!WebPushCrypto.AreUsable(WebPushCrypto.FromBase64Url(p256dh), WebPushCrypto.FromBase64Url(auth)))
+ return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Keys are not valid Web Push keys");
+ var (alerts, policy, invalid) = Data();
+ if (invalid != default)
+ return invalid;
+ var tokenId = TokenId;
+ var accessToken = await AccessToken();
+ if (string.IsNullOrEmpty(tokenId) || string.IsNullOrEmpty(accessToken))
+ return Error(StatusCodes.Status401Unauthorized, "The access token is invalid");
+
+ var subscription = new PushSubscription
+ {
+ AvatarId = MyId,
+ TokenId = tokenId,
+ AccessToken = accessToken,
+ Endpoint = endpoint,
+ P256dh = p256dh,
+ Auth = auth,
+ Standard = Params.Bool("subscription[standard]") == true,
+ Alerts = alerts,
+ Policy = policy
+ };
+ // (two at once with one token: the unique index on the token keeps one, and the later replaces it)
+ for (var attempt = 0; ; attempt++)
+ {
+ await WebPush.ForgetToken(tokenId);
+ try
+ {
+ subscription.ID = default;
+ await DB.Default.SaveAsync(subscription, token);
+ break;
+ }
+ catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey && attempt < 2)
+ {
+ }
+ }
+ return Json(await View(subscription, token));
+ }
+
+ [HttpGet("/api/v1/push/subscription"), Scope("push")]
+ public async Task Show(CancellationToken token) =>
+ await Mine(token) is { } subscription ? Json(await View(subscription, token)) : NotFoundError();
+
+ // Mastodon's update: the data given replaces what was there (alerts left out are off, the policy left out is all)
+ [HttpPut("/api/v1/push/subscription"), Scope("push")]
+ public async Task Update(CancellationToken token)
+ {
+ var subscription = await Mine(token);
+ if (subscription == default)
+ return NotFoundError();
+ var (alerts, policy, invalid) = Data();
+ if (invalid != default)
+ return invalid;
+ subscription.Alerts = alerts;
+ subscription.Policy = policy;
+ subscription.UpdatedAt = DateTime.UtcNow;
+ await DB.Default.Update().MatchID(subscription.ID)
+ .Modify(s => s.Alerts, alerts)
+ .Modify(s => s.Policy, policy)
+ .Modify(s => s.UpdatedAt, subscription.UpdatedAt)
+ .ExecuteAsync(token);
+ return Json(await View(subscription, token));
+ }
+
+ [HttpDelete("/api/v1/push/subscription"), Scope("push")]
+ public async Task Delete(CancellationToken token)
+ {
+ await WebPush.ForgetToken(TokenId);
+ return Json(new { });
+ }
+
+ async Task Mine(CancellationToken token)
+ {
+ var tokenId = TokenId;
+ var avatarId = MyId;
+ return string.IsNullOrEmpty(tokenId) ? default
+ : await DB.Default.Find().Match(s => s.TokenId == tokenId && s.AvatarId == avatarId).ExecuteFirstAsync(token);
+ }
+
+ // data[alerts][] (each true or false; unknown types are ignored, as Mastodon permits only its own) and
+ // data[policy]
+ (List Alerts, PushPolicy Policy, IActionResult Invalid) Data()
+ {
+ var alerts = WebPush.Alerts.Where(alert => Params.Bool($"data[alerts][{alert}]") == true).ToList();
+ var given = Params.Get("data[policy]");
+ if (string.IsNullOrWhiteSpace(given))
+ return (alerts, PushPolicy.All, default);
+ return WebPush.ParsePolicy(given) is { } policy
+ ? (alerts, policy, default)
+ : (alerts, PushPolicy.All, Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Policy is not included in the list"));
+ }
+
+ // the bearer token of this request, wherever the client put it (OpenIddict takes the header, a form field or the
+ // query): the payload carries it back, as Mastodon's does
+ async Task AccessToken()
+ {
+ var stored = await HttpContext.GetTokenAsync(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme,
+ OpenIddictValidationAspNetCoreConstants.Tokens.AccessToken);
+ if (!string.IsNullOrEmpty(stored))
+ return stored;
+ var header = Request.Headers.Authorization.ToString();
+ if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
+ return header["Bearer ".Length..].Trim();
+ return Params.Get("access_token");
+ }
+
+ // Mastodon's WebPushSubscription
+ async Task
\s*
]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ static readonly Regex Tags = new("<[^>]*>", RegexOptions.Compiled);
+ }
+}
diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs
index 1e49039..9721d4e 100644
--- a/PrivaPub/Middleware/SocialPubConfigurations.cs
+++ b/PrivaPub/Middleware/SocialPubConfigurations.cs
@@ -57,7 +57,21 @@ namespace PrivaPub.Middleware
.ConfigurePrimaryHttpMessageHandler(provider =>
SafeHttpHandlerFactory.Create(provider.GetRequiredService>().Value, provider.GetRequiredService()))
.AddHttpMessageHandler(provider => new EdgeHintsHandler(provider.GetRequiredService()));
+ // not federation: Web Push (R9), to the push services clients subscribed with. The same safe handler (public
+ // addresses only, the pasture's exceptions where Federation says so, no redirects), https only (WebPushJob),
+ // small timeouts, and no statistics of where it connects
+ service.AddHttpClient(Infrastructure.Push.WebPushJob.ClientName, (provider, client) =>
+ {
+ var baseAddress = provider.GetRequiredService>().CurrentValue.BackendBaseAddress?.TrimEnd('/');
+ client.Timeout = Infrastructure.Push.WebPushJob.RequestTimeout;
+ client.DefaultRequestHeaders.UserAgent.ParseAdd($"PrivaPub/{BuildInfo.Ref} (+{baseAddress}/)");
+ })
+ .ConfigurePrimaryHttpMessageHandler(provider =>
+ SafeHttpHandlerFactory.Create(provider.GetRequiredService>().Value, connectTimeout: Infrastructure.Push.WebPushJob.ConnectTimeout));
return service
+ .AddSingleton()
+ .AddSingleton()
+ .AddHostedService()
.AddSingleton()
.AddSingleton()
.AddSingleton()
diff --git a/PrivaPub/Models/Jobs/Job.cs b/PrivaPub/Models/Jobs/Job.cs
index fd8b4fc..7654da6 100644
--- a/PrivaPub/Models/Jobs/Job.cs
+++ b/PrivaPub/Models/Jobs/Job.cs
@@ -40,7 +40,8 @@ namespace PrivaPub.Models.Jobs
ExportArchive,
ImportArchive,
SyncFeeds,
- SendEmailVerification
+ SendEmailVerification,
+ SendPush//a Web Push notification to one subscription (R9, WebPushJob)
}
public enum JobState
diff --git a/PrivaPub/Models/PushKeys.cs b/PrivaPub/Models/PushKeys.cs
new file mode 100644
index 0000000..6e28ef7
--- /dev/null
+++ b/PrivaPub/Models/PushKeys.cs
@@ -0,0 +1,18 @@
+using MongoDB.Entities;
+
+namespace PrivaPub.Models
+{
+ // The server's VAPID key pair (RFC 8292, completion program R9): one P-256 key, made the first time something needs it
+ // (Infrastructure/Push/Vapid) and kept here, one document with the fixed id `vapid`. Every push carries a JWT it signs,
+ // and the push services tie each subscription to its public key, which the instance API and app registrations
+ // advertise. Never in a backup and never dropped by a restore (ServerBackup.Excluded): a restored key would break every
+ // subscription a client made since.
+ public class PushKeys : Entity
+ {
+ public const string Name = "vapid";
+
+ public string PublicKey { get; set; }//the uncompressed point (0x04 || X || Y), base64url without padding
+ public string PrivateKey { get; set; }//the private scalar D, base64url without padding
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ }
+}
diff --git a/PrivaPub/Models/Social/ConversationState.cs b/PrivaPub/Models/Social/ConversationState.cs
index ce0e3cf..f19971c 100644
--- a/PrivaPub/Models/Social/ConversationState.cs
+++ b/PrivaPub/Models/Social/ConversationState.cs
@@ -10,6 +10,7 @@ namespace PrivaPub.Models.Social
public string ConversationId { get; set; }
public string LastReadPostId { get; set; }
public string HiddenUpTo { get; set; }
+ public bool MarkedUnread { get; set; }//Mastodon's POST /api/v1/conversations/:id/unread, until it is read again (R9)
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
}
diff --git a/PrivaPub/Models/Social/PushSubscription.cs b/PrivaPub/Models/Social/PushSubscription.cs
new file mode 100644
index 0000000..1aac536
--- /dev/null
+++ b/PrivaPub/Models/Social/PushSubscription.cs
@@ -0,0 +1,34 @@
+using MongoDB.Entities;
+
+namespace PrivaPub.Models.Social
+{
+ // A Web Push subscription (completion program R9). One per Mastodon access token, as on Mastodon (a new one made with the
+ // same token replaces it), and so one persona's, the token's subject: never its login's, so nothing pairs sibling
+ // personas. It holds the token it was made with, which the encrypted payload carries back to the client as Mastodon's
+ // does (`access_token`), so it goes the moment the token does: revoked (/oauth/revoke, its login's sessions ended), its
+ // persona suspended, moved or deleted, a restore. Never in a backup (ServerBackup.Excluded).
+ public class PushSubscription : Entity
+ {
+ public string AvatarId { get; set; }
+ public string TokenId { get; set; }//the OpenIddict token's id; unique
+ public string AccessToken { get; set; }//the bearer token as the client sent it when it subscribed
+ public string Endpoint { get; set; }//the push service's URL for this client
+ public string P256dh { get; set; }//the client's ECDH public key: an uncompressed P-256 point, base64url as it sent it
+ public string Auth { get; set; }//its 16-byte authentication secret, base64url as it sent it
+ public bool Standard { get; set; }//RFC 8291 aes128gcm and RFC 8292 `vapid` when true (Mastodon 4.4), else the aesgcm draft
+ public List Alerts { get; set; } = new();//the alerts turned on, by Mastodon's names (WebPush.Alerts)
+ public PushPolicy Policy { get; set; } = PushPolicy.All;
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
+ }
+
+ // whose notifications are pushed (Mastodon's data[policy]): everyone's, the accounts the persona follows, the accounts
+ // that follow it, nobody's
+ public enum PushPolicy
+ {
+ All,
+ Followed,
+ Follower,
+ None
+ }
+}
diff --git a/PrivaPub/Services/PersonaRemoval.cs b/PrivaPub/Services/PersonaRemoval.cs
index c8114b1..d74b234 100644
--- a/PrivaPub/Services/PersonaRemoval.cs
+++ b/PrivaPub/Services/PersonaRemoval.cs
@@ -95,6 +95,7 @@ namespace PrivaPub.Services
// nothing acts as it while it goes
await _tokens.RevokeBySubjectAsync(avatar.ID, token);
await _authorizations.RevokeBySubjectAsync(avatar.ID, token);
+ await Domain.Social.WebPush.Forget(new[] { avatar.ID });//its Web Push subscriptions with its tokens (R9)
Streams.CloseFor(avatar.ID);
foreach (var group in await _dbEntities.Groups.Match(g => g.OwnerAvatarId == avatar.ID && !g.DeletionAt.HasValue).ExecuteAsync(token))
diff --git a/PrivaPub/Services/RootSessions.cs b/PrivaPub/Services/RootSessions.cs
index 9fa3068..b8a620a 100644
--- a/PrivaPub/Services/RootSessions.cs
+++ b/PrivaPub/Services/RootSessions.cs
@@ -15,7 +15,7 @@ namespace PrivaPub.Services
}
// Ends everything a root is signed in with: its /clientapi tokens, through a new SessionStamp (checked by JwtEvents),
- // and the Mastodon API tokens and authorizations of each of its personas. Used when its password is changed or
+ // and the Mastodon API tokens and authorizations of each of its personas, with their Web Push subscriptions. Used when its password is changed or
// recovered, when it signs out everywhere and when it is deleted. End signs out one session only: the device that
// asked (its Mastodon tokens it revokes itself).
public class RootSessions : IRootSessions
@@ -40,11 +40,14 @@ namespace PrivaPub.Services
.Modify(u => u.SessionStamp, Guid.NewGuid().ToString("N"))
.Modify(u => u.EndedSessions, new List())//the new stamp ends those too
.ExecuteAsync(token);
- foreach (var link in await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootId).ExecuteAsync(token))
+ var links = await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootId).ExecuteAsync(token);
+ foreach (var link in links)
{
await _tokens.RevokeBySubjectAsync(link.AvatarId, token);
await _authorizations.RevokeBySubjectAsync(link.AvatarId, token);
}
+ // their Web Push subscriptions go with their tokens (R9)
+ await Domain.Social.WebPush.Forget(links.Select(l => l.AvatarId).ToList());
}
// remembered as long as one of its tokens could still be valid; the ones that have expired are forgotten meanwhile
diff --git a/docs/MASTODON-API.md b/docs/MASTODON-API.md
new file mode 100644
index 0000000..113f83f
--- /dev/null
+++ b/docs/MASTODON-API.md
@@ -0,0 +1,70 @@
+# The Mastodon client API: what PrivaPub answers
+
+The walk behind the version PrivaPub advertises (completion program R9, 2026-10-18). Every client route of Mastodon 4.3.0,
+4.4.0 and 4.5.0, as their `config/routes/api.rb` and `config/routes.rb` define them, set against PrivaPub's controllers
+(`PrivaPub/Api/Mastodon/Controllers`). Since nothing a client relies on is missing, both instance APIs say
+`4.5.0 (compatible; PrivaPub )` (`InstanceController.Version`; Mastodon invariant 6 in CLAUDE.md), and
+`api_versions.mastodon` is 7, 4.5's own.
+
+- **Served:** the route does what Mastodon's does, within PrivaPub's rules (a token is one persona, per-persona state,
+ located posts outside the Mastodon API).
+- **Stub:** the route answers as Mastodon answers when the feature is off or empty here (an empty list, `{}`, 403, 404 or
+ 422 with Mastodon's message), so clients degrade instead of breaking (Mastodon invariant 5). Each is a decision, named.
+- **Missing:** no route. None of these is the client API.
+
+When a route is added to Mastodon, add it here, and keep the advertised version honest.
+
+## Served
+
+| Area | Routes |
+|---|---|
+| Statuses | `GET /api/v1/statuses` (`id[]`), `POST`, `GET`/`PUT`/`DELETE /api/v1/statuses/:id`, `context`, `history`, `source`, `reblogged_by`, `favourited_by`, `reblog`/`unreblog`, `favourite`/`unfavourite`, `bookmark`/`unbookmark`, `mute`/`unmute`, `pin`/`unpin`; 4.5's `GET …/quotes`, `POST …/quotes/:id/revoke` and `PUT …/interaction_policy` |
+| Timelines | `home`, `public`, `tag/:hashtag`, `list/:id` |
+| Streaming | `/api/v1/streaming` (WebSocket) and `/api/v1/streaming/*` (server-sent events), `health`: `user`, `user:notification`, `public`, `public:local`, `public:remote`, `hashtag`, `hashtag:local`, `list`, `direct` |
+| Accounts | `verify_credentials` (4.4: the `profile` scope too), `update_credentials`, `search`, `lookup`, `relationships`, `familiar_followers` (empty for every account by design: R8), `GET /api/v1/accounts` (`id[]`), `:id`, `:id/statuses`, `followers`, `following`, `lists`, `featured_tags`, `endorsements` (4.4; to the persona itself), `follow`, `unfollow`, `remove_from_followers`, `block`, `unblock`, `mute`, `unmute`, `pin`/`unpin` and 4.4's `endorse`/`unendorse`, `note` |
+| Relationships | `blocks`, `mutes`, `domain_blocks` (`GET`, `POST`, `DELETE`), `domain_blocks/preview` (R9: how many follows and followers the block would take), `follow_requests` with `authorize` and `reject`, `endorsements` |
+| Notifications | v1 `notifications` (index, `:id`, `clear`, `unread_count`, `:id/dismiss`), `notifications/requests` (index, `:id`, `accept`, `dismiss`, the bulk `accept` and `dismiss`, `merged`), `notifications/policy` (`GET`, `PUT`, `PATCH`); v2 grouped `notifications` (index, `:group_key`, `:group_key/accounts`, `:group_key/dismiss`, `unread_count`, `clear` since R9), `notifications/policy` (`GET`, `PATCH`, `PUT` since R9) |
+| Web Push | `POST`, `GET`, `PUT`, `DELETE /api/v1/push/subscription` (R9; CLAUDE.md, Web Push) |
+| Conversations | `conversations` (index), `DELETE :id`, `:id/read`, `:id/unread` (R9) |
+| Media and polls | `POST /api/v1/media`, `/api/v2/media`, `GET`/`PUT`/`DELETE /api/v1/media/:id`; `polls/:id`, `polls/:id/votes` |
+| Lists, filters, tags | `lists` (CRUD, `:id/accounts` `GET`/`POST`/`DELETE`); v1 `filters` and v2 `filters` with `keywords` and `statuses` (both nested and by id); `tags/:name` with `follow`, `unfollow` and 4.4's `feature`, `unfeature`; `followed_tags`; `featured_tags` (index, create, destroy) and `featured_tags/suggestions` |
+| Discovery | `/api/v2/search`, `trends` and `trends/tags`, `trends/statuses`, `trends/links`, `directory`, `suggestions` (v1 index and destroy, v2 index) |
+| The persona's own | `bookmarks`, `favourites`, `markers` (`GET`, `POST`), `preferences`, `scheduled_statuses` (index, show, update, destroy), `profile/avatar` and `profile/header` (`DELETE`), `reports` |
+| The server | v1 and v2 `instance`, `instance/peers` (empty by design: which servers this one talks to is not published), `rules`, `domain_blocks` (the published ones), `extended_description`, `privacy_policy`, 4.4's `terms_of_service` and `terms_of_service/:date`, `languages`, `translation_languages` (none: translation is cut), `activity` (empty unless the administrator publishes it), `custom_emojis`, `announcements` with `dismiss` and `reactions` |
+| Apps and OAuth | `POST /api/v1/apps`, `apps/verify_credentials`, `/oauth/authorize`, `/oauth/token`, `/oauth/revoke`, `/.well-known/oauth-authorization-server`, 4.4's `/oauth/userinfo` (`GET` and `POST`, the `profile` scope; R9) |
+
+## Stubs
+
+| Route | Answer | Why |
+|---|---|---|
+| `POST /api/v1/statuses/:id/translate` | 422 `Translation is not available on this server` | translation is cut (owner decision 2026-10-17) |
+| `GET /api/v1/timelines/link` | `[]` | 4.3's timeline of a trending link: not built (trending links themselves are served) |
+| `GET /api/v1/annual_reports`, 4.4's `GET /api/v1/annual_reports/:year` | `{"annual_reports": [], "accounts": [], "statuses": []}` | no yearly reports are made |
+| 4.4's `POST /api/v1/annual_reports/:year/read` | `{}` | there is none to read |
+| `POST /api/v1/accounts` | 403 `This action is not allowed` (Mastodon's answer with registrations closed) | registration through the API is cut: logins are made by setup or invitation on `/clientapi` |
+| `POST /api/v1/emails/confirmations` | 403 `This method is only available while the e-mail is awaiting confirmation` | no account here waits for a confirmation |
+| `GET /api/v1/emails/check_confirmation` | `true` | the same |
+| `GET /api/v1/peers/search` | `[]` | peers are not published, as `instance/peers` |
+| 4.4's `GET /api/v1_alpha/async_refreshes/:id` | 404 `Record not found` | PrivaPub never sends `Mastodon-Async-Refresh`: a thread's replies are read in the background and show up |
+| `GET /api/v1/accounts/:id/identity_proofs` | `[]` | gone from Mastodon long ago; still asked by old clients |
+| every verb under `/api/v1/admin/**`, `GET`/`POST` under `/api/v2/admin/**` | 403 `This action is not allowed`, signed in or not | the Mastodon admin API is cut: moderation is on `/clientapi` |
+
+## Missing, and not the client API
+
+| Route | Why it is not served |
+|---|---|
+| `GET /api/oembed` | embedding a post in another website; PrivaPub's public pages have no embed view |
+| `/api/web/*` (`settings`, `embeds`, `push_subscriptions`) | Mastodon's own web interface, signed in by its session cookie; PrivaPub's client is decePub on `/clientapi` |
+| `/oauth/authorized_applications` | Doorkeeper's HTML page of a user's applications, in Mastodon's web interface |
+| `/invite/:code` (JSON) | Mastodon's app shows an invitation before registering through the API, which is cut; PrivaPub's invitations are `/clientapi`'s |
+| `/.well-known/host-meta` | OStatus-era discovery; every client and server here uses WebFinger directly |
+| `POST /api/v1/polls` | in 4.3's route list with no action behind it; gone in 4.4 |
+
+Beyond routes: the `public:media` streams (and their `:local` and `:remote` forms) are not offered; the media-only public
+timelines (`only_media`) are, so a client polls them.
+
+## PrivaPub's own, on the same API
+
+`/api/v1/pleroma/statuses/:id/reactions` (Pleroma's emoji reactions), `/api/v1/statuses/:id/downvote` and `undownvote`,
+`/api/v1/feeds` and `/api/v1/timelines/feed/:id` (PieFed's and Lemmy's feeds), and `/api/privapub/v1/*` (provenance,
+servers, CDNs, events' join and leave): Mastodon invariant 7.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 7fc2eb3..179344c 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -154,6 +154,24 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
settings form; a `Move` carries the persona's endorsement and note to the new account; featured tags, notes and
endorsements are left out of the persona archive (Mastodon's layout has none), its `following_accounts.csv` now
carries notify and languages.
+ - R9 Web Push, then the honest version (built 2026-10-18, not yet deployed; `tools/pasture/scenarios/push.sh` with the
+ pasture's own push service, `tools/pasture/pushsink`): the server's VAPID key (`PushKeys`, made on first need, in
+ Mongo, never in a backup), Mastodon's `/api/v1/push/subscription` (create, show, update, delete; one subscription
+ per access token, so per persona), every notification kept (never a filtered one) pushed to the subscriptions whose
+ alert and policy admit it, as `SendPush` jobs through the `push` client, encrypted as RFC 8291 `aes128gcm` or the
+ `aesgcm` draft, signed with an ES256 VAPID JWT; subscriptions end with their tokens (revocation, sessions ended,
+ suspension, deletion, a restore). Then the walk of every Mastodon 4.3, 4.4 and 4.5 client route
+ (`docs/MASTODON-API.md`), with what it found missing built (`conversations/:id/unread`, `domain_blocks/preview`, v2
+ `notifications/clear` and `PUT` policy, `/oauth/userinfo` and the `profile` scope on `verify_credentials`) or answered
+ as stubs (annual reports by year, API registration 403, e-mail confirmation, peer search, async refreshes), and both
+ instance APIs advertising `4.5.0 (compatible; PrivaPub )`. Chosen while building it, for the owner to
+ confirm: the payload carries the subscription's own access token, as Mastodon's does (encrypted to that client
+ alone); what a persona tells itself (its poll ended, the staff's warning) passes the `followed` and `follower` policies
+ (Mastodon would hold it back); a push service's 4xx other than 404, 410, 408 and 429 drops that push and keeps the
+ subscription (Mastodon deletes it); the JWT lasts twelve hours (Mastodon's a day); titles are Mastodon's English
+ subjects and `preferred_locale` is the persona's posting language (no persona has an interface language); six
+ attempts on the jobs' backoff (Mastodon retries five times); `alerts` answers every alert true or false (Mastodon
+ echoes those sent); the key lives in Mongo, not in the configuration; `/oauth/userinfo` is served (Mastodon 4.4's).
## Intent
@@ -600,7 +618,8 @@ The first refactor commit is a pure move with namespaces only. Logic changes fol
- `Link` paging on `max_id`, `since_id` and `min_id`; CORS exposes `Link`.
- `Idempotency-Key` honoured on posting statuses.
- **Endpoints:**
- - **Instance:** v1 and v2, advertising version `4.2.0 (compatible; PrivaPub)`.
+ - **Instance:** v1 and v2, advertising version `4.2.0 (compatible; PrivaPub)`. *`4.5.0 (compatible; PrivaPub
+ )` since R9 (2026-10-18), after the walk in `docs/MASTODON-API.md`.*
- **Accounts:** `verify_credentials`, `update_credentials`, `:id`, `:id/statuses`, `lookup`, `relationships`,
`search`, followers and following, follow and unfollow, `follow_requests`.
- **Statuses:** CRUD, edit, `context`, `history`, `source`, favourite and reblog plus their undos (outbound Like and
@@ -829,7 +848,8 @@ it, raw where it doesn't.
- **Mastodon API:** ~~streaming WebSocket~~ (done 2026-10-05: `/api/v1/streaming` as a WebSocket and as server-sent
events, user, notification, public, hashtag and list streams, each event mapped for its persona; a deletion reaches
only the streams that showed the post), Web Push (gated: outbound traffic to push services; approved 2026-10-17, C R9), grouped
- notifications. Then advertise an honest version.
+ notifications. Then advertise an honest version. *Done in R9 (2026-10-18): Web Push, then `4.5.0 (compatible; PrivaPub
+ )` once every 4.3, 4.4 and 4.5 client route was served or a named stub (`docs/MASTODON-API.md`).*
- **Backfill:** an author's outbox after following them: **done 2026-10-05** (`OutboxBackfill`, the first page's
public posts, twenty at most, once a day; checked live against Mastodon).
- **Long tail:**
diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile
index 55dba70..3258430 100644
--- a/tools/pasture/Caddyfile
+++ b/tools/pasture/Caddyfile
@@ -207,6 +207,11 @@ lemmy19.test {
reverse_proxy pasture-lemmy19:8536
}
+pushsink.test {
+ tls internal
+ reverse_proxy pasture-pushsink:8080
+}
+
flood1.test, flood2.test, flood3.test, flood4.test, flood5.test, flood6.test, flood7.test, flood8.test, flood9.test, flood10.test, flood11.test, flood12.test, flood13.test, flood14.test, flood15.test, flood16.test, flood17.test, flood18.test, flood19.test, flood20.test {
tls internal
reverse_proxy pasture-flood:8080
diff --git a/tools/pasture/lib/interop.sh b/tools/pasture/lib/interop.sh
index 7621506..a4aa571 100644
--- a/tools/pasture/lib/interop.sh
+++ b/tools/pasture/lib/interop.sh
@@ -42,14 +42,14 @@ privapub_root() {
echo "$root" | j "print(d['token'])"
}
-# privapub_token : creates the persona under the pasture root if needed and returns a Mastodon token for it,
-# through the real OAuth flow (tools/smoke/oauth.sh).
+# privapub_token [scopes]: creates the persona under the pasture root if needed and returns a Mastodon token for
+# it, through the real OAuth flow (tools/smoke/oauth.sh); "read write follow" unless other scopes are asked for
privapub_token() {
- local persona=$1 jwt
+ local persona=$1 scopes=${2:-read write follow} jwt
jwt=$(privapub_root)
curl -s -o /dev/null -X POST $P/clientapi/avatar/private/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
-d "{\"userName\":\"$persona\",\"name\":\"$persona of PrivaPub\",\"biography\":\"testing federation\"}"
- "$here/../smoke/oauth.sh" "$P" "$ROOT_USER" "$ROOT_PASS" "$persona" "read write follow" | cut -d' ' -f1
+ "$here/../smoke/oauth.sh" "$P" "$ROOT_USER" "$ROOT_PASS" "$persona" "$scopes" | cut -d' ' -f1
}
# p_report : a persona reports an account's posts and asks for
diff --git a/tools/pasture/peers/pushsink.sh b/tools/pasture/peers/pushsink.sh
new file mode 100644
index 0000000..653993b
--- /dev/null
+++ b/tools/pasture/peers/pushsink.sh
@@ -0,0 +1,22 @@
+# pushsink: a Web Push service as pushsink.test, for scenarios/push.sh (tools/pasture/pushsink/pushsink.cs, a .NET
+# file-based app published once into .pushsink and run on the runtime image PrivaPub uses). It makes each subscription's
+# keys itself, takes PrivaPub's pushes as a push service would (the VAPID JWT checked against the instance API's key, the
+# aes128gcm or aesgcm body decrypted) and keeps what it took for the scenario to read: https://pushsink.test:6443/
+# subscriptions//received. It can be told to answer the next push otherwise (/subscriptions//answer, a 410).
+# PrivaPub reaches it through Caddy as it reaches every peer: its push client honours appsettings.Pasture.json's
+# Federation exceptions (AcceptAnyCertificate for Caddy's CA, AllowPrivateNetworks), which production refuses at start.
+pushsink_up() {
+ local dotnet=${DOTNET:-$(command -v dotnet || echo ~/.dotnet/dotnet)}
+ if [ ! -x "$here/.pushsink/pushsink" ] || [ "$here/pushsink/pushsink.cs" -nt "$here/.pushsink/pushsink" ]; then
+ "$dotnet" publish "$here/pushsink/pushsink.cs" -r linux-x64 --self-contained true -o "$here/.pushsink" -v quiet
+ fi
+ podman run -d --replace --name pasture-pushsink --network $net --label pasture=1 -w /app -v "$here/.pushsink:/app:Z,ro" \
+ -v "$ca/bundle.pem:/etc/ssl/certs/ca-certificates.crt:z,ro" -e SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
+ -e PUSHSINK_BASE=https://pushsink.test -e PUSHSINK_INSTANCE=https://privapub.test \
+ mcr.microsoft.com/dotnet/runtime-deps:10.0 /app/pushsink >/dev/null
+ for _ in $(seq 1 60); do
+ site pushsink.test -s -o /dev/null -w '%{http_code}' https://pushsink.test:6443/health 2>/dev/null | grep -q 200 && break
+ sleep 1
+ done
+ echo "pushsink: https://pushsink.test:6443"
+}
diff --git a/tools/pasture/pushsink/pushsink.cs b/tools/pasture/pushsink/pushsink.cs
new file mode 100644
index 0000000..307ef6c
--- /dev/null
+++ b/tools/pasture/pushsink/pushsink.cs
@@ -0,0 +1,465 @@
+#:sdk Microsoft.NET.Sdk.Web
+#:property PublishAot=false
+#:property InvariantGlobalization=true
+#:property Nullable=disable
+
+// pushsink: a Web Push service for the pasture (tools/pasture/scenarios/push.sh), behind Caddy as pushsink.test, which
+// takes PrivaPub's pushes as a browser's push service would and keeps what it took for the scenario to read. It makes the
+// keys of each subscription itself, so it can read what PrivaPub encrypted to them, and it checks what a push service
+// checks: the VAPID JWT (ES256 by the server's key, the push service's origin as aud, exp in the future and at most a day
+// ahead, a mailto: or https: sub) and the encryption (RFC 8291 aes128gcm, or the aesgcm draft with Encryption and
+// Crypto-Key). Its own code, not PrivaPub's, so a round trip proves the two agree.
+// POST /subscriptions {"server_key": ""} (optional; without it the
+// key is read from $PUSHSINK_INSTANCE/api/v2/instance): a new subscription, its
+// keys made here: {"id", "endpoint", "keys": {"p256dh", "auth"}}
+// GET /subscriptions/{id} the subscription, with what it answers next
+// POST /subscriptions/{id}/answer {"status": 410, "times": 1}: the next push(es) are answered so, after being read
+// GET /subscriptions/{id}/received every push it took or refused, oldest first: when, the status it answered,
+// whether every check passed (ok) and the errors, the headers, the JWT's claims,
+// the decrypted payload (parsed JSON)
+// DELETE /subscriptions/{id}/received forgets them
+// POST /push/{id} the push service: 201 when every check passes, 403 for the VAPID's failures, 400
+// for the rest, 404 for no such subscription, or the status asked for
+// GET /health
+// Everything is in memory: a new container starts empty.
+using System.Buffers.Binary;
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+var publicBase = (Environment.GetEnvironmentVariable("PUSHSINK_BASE") ?? "https://pushsink.test").TrimEnd('/');
+var instance = (Environment.GetEnvironmentVariable("PUSHSINK_INSTANCE") ?? "https://privapub.test").TrimEnd('/');
+var listen = Environment.GetEnvironmentVariable("PUSHSINK_LISTEN") ?? "http://0.0.0.0:8080";
+var subscriptions = new ConcurrentDictionary();
+var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
+
+var builder = WebApplication.CreateBuilder();
+builder.Logging.SetMinimumLevel(LogLevel.Warning);
+builder.WebHost.UseUrls(listen);
+builder.WebHost.ConfigureKestrel(k => k.Limits.MaxRequestBodySize = 64 * 1024);
+var app = builder.Build();
+
+app.MapGet("/health", () => Results.Json(new { ok = true, subscriptions = subscriptions.Count }));
+
+app.MapPost("/subscriptions", async (HttpContext context) =>
+{
+ var request = context.Request.ContentLength > 0 ? await JsonNode.ParseAsync(context.Request.Body) : null;
+ var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
+ var subscription = new Subscription(Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(12)), key, RandomNumberGenerator.GetBytes(16),
+ request?["server_key"]?.GetValue());
+ subscriptions[subscription.Id] = subscription;
+ return Results.Json(subscription.View(publicBase));
+});
+
+app.MapGet("/subscriptions/{id}", (string id) =>
+ subscriptions.TryGetValue(id, out var subscription) ? Results.Json(subscription.View(publicBase)) : Results.NotFound());
+
+app.MapPost("/subscriptions/{id}/answer", async (HttpContext context, string id) =>
+{
+ if (!subscriptions.TryGetValue(id, out var subscription))
+ return Results.NotFound();
+ var request = await JsonNode.ParseAsync(context.Request.Body);
+ var status = request?["status"]?.GetValue() ?? 0;
+ if (status is < 200 or > 599)
+ return Results.BadRequest(new { error = "status: 200 to 599" });
+ subscription.Answer(status, request?["times"]?.GetValue() ?? 1);
+ return Results.Json(subscription.View(publicBase));
+});
+
+app.MapGet("/subscriptions/{id}/received", (string id) =>
+ subscriptions.TryGetValue(id, out var subscription) ? Results.Text(subscription.Received().ToJsonString(), "application/json") : Results.NotFound());
+
+app.MapDelete("/subscriptions/{id}/received", (string id) =>
+{
+ if (!subscriptions.TryGetValue(id, out var subscription))
+ return Results.NotFound();
+ subscription.Forget();
+ return Results.Json(new { });
+});
+
+app.MapPost("/push/{id}", async (HttpContext context, string id) =>
+{
+ if (!subscriptions.TryGetValue(id, out var subscription))
+ return Results.NotFound();
+ using var buffer = new MemoryStream();
+ await context.Request.Body.CopyToAsync(buffer);
+ var check = new Check(context.Request, buffer.ToArray(), subscription, publicBase);
+ var serverKey = subscription.ServerKey ?? await InstanceKey();
+ check.Run(serverKey);
+ var forced = subscription.NextAnswer();
+ var status = forced ?? (check.Errors.Count == 0 ? 201 : check.VapidFailed ? 403 : 400);
+ subscription.Keep(check.Record(status));
+ if (status == 201)
+ context.Response.Headers.Location = $"{publicBase}/messages/{Guid.NewGuid():N}";
+ return Results.StatusCode(status);
+});
+
+app.Run();
+return 0;
+
+// the server's key as its instance API gives it (configuration.vapid.public_key), read once a minute at most
+async Task InstanceKey()
+{
+ if (InstanceCache.Key is { } known && DateTime.UtcNow - InstanceCache.At < TimeSpan.FromMinutes(1))
+ return known;
+ try
+ {
+ var document = JsonNode.Parse(await http.GetStringAsync(instance + "/api/v2/instance"));
+ InstanceCache.Key = document?["configuration"]?["vapid"]?["public_key"]?.GetValue();
+ InstanceCache.At = DateTime.UtcNow;
+ }
+ catch (Exception)
+ {
+ InstanceCache.Key = null;
+ }
+ return InstanceCache.Key;
+}
+
+static class InstanceCache
+{
+ public static string Key;
+ public static DateTime At;
+}
+
+sealed class Subscription(string id, ECDiffieHellman key, byte[] auth, string serverKey)
+{
+ readonly Lock _gate = new();
+ readonly List _received = new();
+ int _answer;
+ int _answerTimes;
+
+ public string Id => id;
+ public ECDiffieHellman Key => key;
+ public byte[] Auth => auth;
+ public string ServerKey => serverKey;
+ public byte[] PublicPoint
+ {
+ get
+ {
+ var parameters = key.ExportParameters(false);
+ return [0x04, .. parameters.Q.X, .. parameters.Q.Y];
+ }
+ }
+
+ public JsonObject View(string publicBase)
+ {
+ lock (_gate)
+ return new JsonObject
+ {
+ ["id"] = id,
+ ["endpoint"] = $"{publicBase}/push/{id}",
+ ["keys"] = new JsonObject { ["p256dh"] = B64.Url(PublicPoint), ["auth"] = B64.Url(auth) },
+ ["server_key"] = serverKey,
+ ["next_answer"] = _answerTimes > 0 ? _answer : null,
+ ["next_answer_times"] = _answerTimes,
+ ["received"] = _received.Count
+ };
+ }
+
+ public void Answer(int status, int times)
+ {
+ lock (_gate)
+ {
+ _answer = status;
+ _answerTimes = Math.Max(1, times);
+ }
+ }
+
+ public int? NextAnswer()
+ {
+ lock (_gate)
+ {
+ if (_answerTimes <= 0)
+ return null;
+ _answerTimes--;
+ return _answer;
+ }
+ }
+
+ public void Keep(JsonObject record)
+ {
+ lock (_gate)
+ _received.Add(record);
+ }
+
+ public JsonArray Received()
+ {
+ lock (_gate)
+ return new JsonArray(_received.Select(r => (JsonNode)r.DeepClone()).ToArray());
+ }
+
+ public void Forget()
+ {
+ lock (_gate)
+ _received.Clear();
+ }
+}
+
+// what a push service checks of one push, and what the sink keeps of it
+sealed class Check(HttpRequest request, byte[] body, Subscription subscription, string publicBase)
+{
+ public List Errors { get; } = new();
+ public bool VapidFailed { get; private set; }
+ JsonObject _claims;
+ string _scheme;
+ string _k;
+ JsonNode _payload;
+ string _encoding;
+
+ public void Run(string expectedServerKey)
+ {
+ _encoding = request.Headers.ContentEncoding.ToString().Trim().ToLowerInvariant();
+ if (_encoding is not ("aes128gcm" or "aesgcm"))
+ Errors.Add($"Content-Encoding is '{_encoding}', neither aes128gcm nor aesgcm");
+ if (!int.TryParse(request.Headers["TTL"].ToString(), out var ttl) || ttl < 0)
+ Errors.Add("no TTL header (RFC 8030 5.2)");
+ Vapid(expectedServerKey);
+ try
+ {
+ var plaintext = _encoding switch
+ {
+ "aes128gcm" => Decrypt.Aes128Gcm(body, subscription.Key, subscription.PublicPoint, subscription.Auth),
+ "aesgcm" => Decrypt.AesGcm(body, Param(request.Headers["Encryption"].ToString(), "salt"), Param(request.Headers["Crypto-Key"].ToString(), "dh"),
+ subscription.Key, subscription.PublicPoint, subscription.Auth),
+ _ => null
+ };
+ if (plaintext != null)
+ _payload = JsonNode.Parse(plaintext);
+ }
+ catch (Exception ex) when (ex is CryptographicException or FormatException or JsonException or ArgumentException or IndexOutOfRangeException)
+ {
+ Errors.Add("the body could not be decrypted: " + ex.Message);
+ }
+ }
+
+ // RFC 8292 3: Authorization: vapid t=, k=; the draft's: Authorization: WebPush and Crypto-Key's p256ecdsa
+ void Vapid(string expectedServerKey)
+ {
+ var authorization = request.Headers.Authorization.ToString().Trim();
+ var space = authorization.IndexOf(' ');
+ _scheme = space > 0 ? authorization[..space] : authorization;
+ var rest = space > 0 ? authorization[(space + 1)..].Trim() : string.Empty;
+ string jwt;
+ if (_scheme.Equals("vapid", StringComparison.OrdinalIgnoreCase))
+ {
+ jwt = Param(rest, "t");
+ _k = Param(rest, "k");
+ }
+ else if (_scheme.Equals("WebPush", StringComparison.OrdinalIgnoreCase))
+ {
+ jwt = rest;
+ _k = Param(request.Headers["Crypto-Key"].ToString(), "p256ecdsa");
+ }
+ else
+ {
+ Fail($"Authorization's scheme is '{_scheme}', neither vapid nor WebPush");
+ return;
+ }
+ if (_encoding == "aes128gcm" && !_scheme.Equals("vapid", StringComparison.OrdinalIgnoreCase))
+ Fail("an aes128gcm push carries RFC 8292's vapid authorization");
+ var key = B64.Decode(_k);
+ if (key is not { Length: 65 } || key[0] != 0x04)
+ {
+ Fail("the VAPID key (k or p256ecdsa) is no uncompressed P-256 point");
+ return;
+ }
+ if (string.IsNullOrEmpty(expectedServerKey))
+ Fail("the server's key is unknown (no server_key given, and the instance API did not say)");
+ else if (!B64.Decode(expectedServerKey).AsSpan().SequenceEqual(key))
+ Fail("the VAPID key is not the server's (the instance API's vapid.public_key)");
+ var parts = jwt?.Split('.') ?? [];
+ if (parts.Length != 3)
+ {
+ Fail("the VAPID token is no JWT");
+ return;
+ }
+ JsonNode header;
+ try
+ {
+ header = JsonNode.Parse(B64.Decode(parts[0]));
+ _claims = JsonNode.Parse(B64.Decode(parts[1])) as JsonObject;
+ }
+ catch (Exception)
+ {
+ Fail("the JWT's header or claims are not JSON");
+ return;
+ }
+ if (header?["alg"]?.GetValue() != "ES256")
+ Fail($"the JWT's alg is '{header?["alg"]}', not ES256");
+ using (var verifier = ECDsa.Create(new ECParameters { Curve = ECCurve.NamedCurves.nistP256, Q = new ECPoint { X = key[1..33], Y = key[33..] } }))
+ {
+ var signature = B64.Decode(parts[2]);
+ if (signature is not { Length: 64 } || !verifier.VerifyData(Encoding.ASCII.GetBytes(parts[0] + "." + parts[1]), signature, HashAlgorithmName.SHA256,
+ DSASignatureFormat.IeeeP1363FixedFieldConcatenation))
+ Fail("the JWT's ES256 signature does not verify with the VAPID key");
+ }
+ var audience = new Uri($"{publicBase}/push/{subscription.Id}").GetLeftPart(UriPartial.Authority);
+ if (_claims?["aud"]?.GetValue() != audience)
+ Fail($"the JWT's aud is '{_claims?["aud"]}', not the push service's origin {audience}");
+ var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ var exp = _claims?["exp"]?.GetValue() ?? 0;
+ if (exp <= now)
+ Fail("the JWT has expired (exp)");
+ else if (exp > now + 24 * 3600 + 60)
+ Fail("the JWT's exp is more than 24 hours ahead (RFC 8292 2)");
+ var sub = _claims?["sub"]?.GetValue() ?? string.Empty;
+ if (!sub.StartsWith("mailto:", StringComparison.Ordinal) && !sub.StartsWith("https:", StringComparison.Ordinal))
+ Fail($"the JWT's sub is '{sub}', neither mailto: nor https:");
+ }
+
+ void Fail(string error)
+ {
+ VapidFailed = true;
+ Errors.Add(error);
+ }
+
+ // a parameter of a header written a=b, c=d or a=b;c=d (Crypto-Key may hold several)
+ static string Param(string header, string name)
+ {
+ foreach (var part in (header ?? string.Empty).Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ var equals = part.IndexOf('=');
+ if (equals > 0 && part[..equals].Trim().Equals(name, StringComparison.OrdinalIgnoreCase))
+ return part[(equals + 1)..].Trim().Trim('"');
+ }
+ return null;
+ }
+
+ public JsonObject Record(int status) => new()
+ {
+ ["at"] = DateTime.UtcNow.ToString("O"),
+ ["status"] = status,
+ ["ok"] = Errors.Count == 0,
+ ["errors"] = new JsonArray(Errors.Select(e => (JsonNode)e).ToArray()),
+ ["encoding"] = _encoding,
+ ["headers"] = new JsonObject
+ {
+ ["ttl"] = request.Headers["TTL"].ToString(),
+ ["urgency"] = request.Headers["Urgency"].ToString(),
+ ["content_type"] = request.ContentType,
+ ["content_encoding"] = request.Headers.ContentEncoding.ToString(),
+ ["authorization_scheme"] = _scheme,
+ ["encryption"] = request.Headers["Encryption"].ToString(),
+ ["crypto_key"] = request.Headers["Crypto-Key"].ToString(),
+ ["user_agent"] = request.Headers.UserAgent.ToString()
+ },
+ ["vapid"] = new JsonObject
+ {
+ ["k"] = _k,
+ ["aud"] = _claims?["aud"]?.DeepClone(),
+ ["exp"] = _claims?["exp"]?.DeepClone(),
+ ["sub"] = _claims?["sub"]?.DeepClone()
+ },
+ ["bytes"] = body.Length,
+ ["payload"] = _payload?.DeepClone()
+ };
+}
+
+// the client's side of RFC 8291 and of the aesgcm draft
+static class Decrypt
+{
+ public static byte[] Aes128Gcm(byte[] body, ECDiffieHellman client, byte[] clientPublic, byte[] auth)
+ {
+ // RFC 8188 2.1: salt (16), record size (4), key id length (1), key id: the sender's public key
+ var salt = body[..16];
+ var recordSize = (int)BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(16, 4));
+ var idLength = body[20];
+ var serverPublic = body[21..(21 + idLength)];
+ if (recordSize < 18)
+ throw new CryptographicException($"record size {recordSize} is below 18");
+ var shared = Agree(client, serverPublic);
+ var keyInfo = Concat(Encoding.ASCII.GetBytes("WebPush: info\0"), clientPublic, serverPublic);
+ var ikm = HKDF.Expand(HashAlgorithmName.SHA256, HKDF.Extract(HashAlgorithmName.SHA256, shared, auth), 32, keyInfo);
+ var prk = HKDF.Extract(HashAlgorithmName.SHA256, ikm, salt);
+ var cek = HKDF.Expand(HashAlgorithmName.SHA256, prk, 16, Encoding.ASCII.GetBytes("Content-Encoding: aes128gcm\0"));
+ var nonce = HKDF.Expand(HashAlgorithmName.SHA256, prk, 12, Encoding.ASCII.GetBytes("Content-Encoding: nonce\0"));
+ var content = body[(21 + idLength)..];
+ var plaintext = new List();
+ var records = (content.Length + recordSize - 1) / recordSize;
+ for (var i = 0; i < records; i++)
+ {
+ var record = content[(i * recordSize)..Math.Min(content.Length, (i + 1) * recordSize)];
+ var recordNonce = (byte[])nonce.Clone();
+ for (var b = 0; b < 8; b++)//the record's sequence number, XORed into the nonce's last bytes
+ recordNonce[11 - b] ^= (byte)((long)i >> (8 * b));
+ var opened = Open(cek, recordNonce, record);
+ var end = opened.Length - 1;
+ while (end >= 0 && opened[end] == 0)
+ end--;
+ var last = i == records - 1;
+ if (end < 0 || opened[end] != (last ? 0x02 : 0x01))
+ throw new CryptographicException("a record's delimiter is wrong");
+ plaintext.AddRange(opened[..end]);
+ }
+ return plaintext.ToArray();
+ }
+
+ public static byte[] AesGcm(byte[] body, string salt, string serverKey, ECDiffieHellman client, byte[] clientPublic, byte[] auth)
+ {
+ var saltBytes = B64.Decode(salt) ?? throw new FormatException("no salt in the Encryption header");
+ var serverPublic = B64.Decode(serverKey) ?? throw new FormatException("no dh in the Crypto-Key header");
+ var shared = Agree(client, serverPublic);
+ var ikm = HKDF.Expand(HashAlgorithmName.SHA256, HKDF.Extract(HashAlgorithmName.SHA256, shared, auth), 32, Encoding.ASCII.GetBytes("Content-Encoding: auth\0"));
+ var context = Concat(Encoding.ASCII.GetBytes("P-256\0"), Length16(clientPublic), clientPublic, Length16(serverPublic), serverPublic);
+ var prk = HKDF.Extract(HashAlgorithmName.SHA256, ikm, saltBytes);
+ var cek = HKDF.Expand(HashAlgorithmName.SHA256, prk, 16, Concat(Encoding.ASCII.GetBytes("Content-Encoding: aesgcm\0"), context));
+ var nonce = HKDF.Expand(HashAlgorithmName.SHA256, prk, 12, Concat(Encoding.ASCII.GetBytes("Content-Encoding: nonce\0"), context));
+ var opened = Open(cek, nonce, body);
+ var padding = BinaryPrimitives.ReadUInt16BigEndian(opened);
+ return opened[(2 + padding)..];
+ }
+
+ static byte[] Agree(ECDiffieHellman client, byte[] serverPublic)
+ {
+ if (serverPublic is not { Length: 65 } || serverPublic[0] != 0x04)
+ throw new CryptographicException("the sender's key is no uncompressed P-256 point");
+ using var server = ECDiffieHellman.Create(new ECParameters
+ {
+ Curve = ECCurve.NamedCurves.nistP256,
+ Q = new ECPoint { X = serverPublic[1..33], Y = serverPublic[33..] }
+ });
+ return client.DeriveRawSecretAgreement(server.PublicKey);
+ }
+
+ static byte[] Open(byte[] key, byte[] nonce, byte[] sealedRecord)
+ {
+ if (sealedRecord.Length < 16)
+ throw new CryptographicException("a record shorter than its tag");
+ var plaintext = new byte[sealedRecord.Length - 16];
+ using var aes = new AesGcm(key, 16);
+ aes.Decrypt(nonce, sealedRecord.AsSpan(0, plaintext.Length), sealedRecord.AsSpan(plaintext.Length), plaintext);
+ return plaintext;
+ }
+
+ static byte[] Length16(byte[] value)
+ {
+ var length = new byte[2];
+ BinaryPrimitives.WriteUInt16BigEndian(length, (ushort)value.Length);
+ return length;
+ }
+
+ static byte[] Concat(params byte[][] parts) => parts.SelectMany(p => p).ToArray();
+}
+
+static class B64
+{
+ public static string Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+
+ public static byte[] Decode(string text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ return null;
+ var normal = text.Trim().TrimEnd('=').Replace('-', '+').Replace('_', '/');
+ normal = normal.PadRight(normal.Length + (4 - normal.Length % 4) % 4, '=');
+ try
+ {
+ return Convert.FromBase64String(normal);
+ }
+ catch (FormatException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/tools/pasture/scenarios/push.sh b/tools/pasture/scenarios/push.sh
new file mode 100644
index 0000000..8e662d6
--- /dev/null
+++ b/tools/pasture/scenarios/push.sh
@@ -0,0 +1,168 @@
+# Web Push (completion program R9), with Mastodon and the pushsink. push_alice subscribes with keys the sink made for
+# her: a mention from mastouser reaches the sink encrypted (aes128gcm), signed by the server's VAPID key for the sink's
+# origin, and decrypts to Mastodon's payload with notification_type mention and her own access token. Turning the mention
+# alert off stops it while a favourite still comes; policy=followed stops mastouser's mention until she follows him;
+# standard=false is the aesgcm draft and decrypts too, and a new subscription replaces the old; push_bob, a sibling persona
+# of the same login, never gets hers, nor she his; DELETE stops the pushes; the sink answering 410 removes the
+# subscription; a revoked token takes its subscription with it. Needs the mastodon and pushsink peers (batch A); run
+# after featured.
+M=https://mastodon.test:6443
+mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; }
+PS=https://pushsink.test:6443
+scurl() { curl -sk --resolve pushsink.test:6443:127.0.0.1 "$@"; }
+. "$here/peers/mastodon.sh"
+code() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
+# sink_new: a subscription of the sink's own, its keys made there, told the server's key to check the VAPID JWT with
+sink_new() { scurl -X POST "$PS/subscriptions" -H 'Content-Type: application/json' -d "{\"server_key\":\"$server_key\"}"; }
+sink_count() { scurl "$PS/subscriptions/$1" | j "print(d['received'])"; }
+# sink_last
+sink_last() { scurl "$PS/subscriptions/$1/received" | j "$2"; }
+sink_waits() { local sid=$1 want=$2; until_true 30 '[ "$(sink_count "$sid")" -ge "$want" ]'; }
+# subscribe [policy=]: Mastodon's form
+subscribe() {
+ local auth=$1 sink=$2 standard=$3 args=() a
+ shift 3
+ for a in "$@"; do
+ case $a in policy=*) args+=(-d "data[policy]=${a#policy=}") ;; *) args+=(-d "data[alerts][$a]=true") ;; esac
+ done
+ curl -s -X POST -H "$auth" "$P/api/v1/push/subscription" \
+ --data-urlencode "subscription[endpoint]=$(echo "$sink" | j "print(d['endpoint'])")" \
+ --data-urlencode "subscription[keys][p256dh]=$(echo "$sink" | j "print(d['keys']['p256dh'])")" \
+ --data-urlencode "subscription[keys][auth]=$(echo "$sink" | j "print(d['keys']['auth'])")" \
+ -d "subscription[standard]=$standard" "${args[@]}"
+}
+# m_mention : mastouser's public post naming a persona here
+m_mention() { mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/statuses" --data-urlencode "status=@$1@privapub.test $2" -d visibility=public; }
+# notified : the persona holds a mention with these words (it arrived, pushed or not)
+notified() { curl -s -H "$1" "$P/api/v1/notifications?types[]=mention&limit=30" | j "print(any('$2' in (n.get('status') or {}).get('content', '') for n in d))"; }
+# arrives_unpushed : the mention arrived and the sink got nothing
+arrives_unpushed() {
+ local auth=$1 words=$2 sid=$3 had=$4
+ until_true 30 '[ "$(notified "$auth" "$words")" = True ]' || return 1
+ sleep 6
+ [ "$(sink_count "$sid")" = "$had" ]
+}
+
+echo "push"
+run=$(date +%s)
+AT=$(privapub_token push_alice "read write follow push"); AH="Authorization: Bearer $AT"
+BT=$(privapub_token push_bob "read write follow push"); BH="Authorization: Bearer $BT"
+MT=$(mastodon_token); MH="Authorization: Bearer $MT"
+[ -n "$AT" ] && [ -n "$BT" ] && [ -n "$MT" ] && ok "tokens for push_alice, push_bob (her sibling, the same login) and mastouser" \
+ || { ko "a token is missing"; return 1; }
+
+echo " the server's key"
+server_key=$(curl -s "$P/api/v2/instance" | j "print(d['configuration']['vapid']['public_key'])")
+[ "$(python3 -c "import base64,sys; k=base64.urlsafe_b64decode(sys.argv[1]); print(len(k) == 65 and k[0] == 4)" "$server_key" 2>/dev/null)" = True ] \
+ && ok "the instance API gives the VAPID key (an uncompressed P-256 point)" || ko "configuration.vapid: $server_key"
+[ "$(curl -s -X POST "$P/api/v1/apps" -d client_name=push-check -d redirect_uris=urn:ietf:wg:oauth:2.0:oob -d scopes=push | j "print(d['vapid_key'])")" = "$server_key" ] \
+ && ok "an app's vapid_key is the same key" || ko "the app's vapid_key differs"
+[ "$(curl -s "$P/api/v1/instance" | j "print(d['version'].startswith('4.5.0 (compatible; PrivaPub'))")" = True ] \
+ && [ "$(curl -s "$P/api/v2/instance" | j "print(d['version'].startswith('4.5.0 (compatible; PrivaPub') and d['api_versions']['mastodon'] == 7)")" = True ] \
+ && ok "both instance APIs say 4.5.0 (compatible; PrivaPub ...)" || ko "the version: $(curl -s "$P/api/v2/instance" | j "print(d['version'])")"
+
+echo " a mention, pushed"
+m_on_p=$(curl -s -H "$AH" "$P/api/v2/search?q=mastouser@mastodon.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
+# (a run before may have left the follow the policy check needs gone)
+curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/unfollow"
+for persona in push_alice push_bob; do
+ mcurl -o /dev/null -H "$MH" "$M/api/v2/search?q=@$persona@privapub.test&resolve=true&type=accounts"
+done
+sa=$(sink_new); sa_id=$(echo "$sa" | j "print(d['id'])")
+made=$(subscribe "$AH" "$sa" true mention favourite policy=all)
+[ "$(echo "$made" | j "print(d['standard'] is True and d['alerts']['mention'] is True and d['alerts']['reblog'] is False and d['policy'] == 'all' and d['server_key'] == '$server_key' and d['endpoint'].startswith('https://pushsink.test/push/'))")" = True ] \
+ && ok "push_alice subscribes with the sink's keys (standard)" || ko "the subscription: $(echo "$made" | head -c 400)"
+[ "$(curl -s -H "$AH" "$P/api/v1/push/subscription" | j "print(d['id'])")" = "$(echo "$made" | j "print(d['id'])")" ] \
+ && ok "GET answers it" || ko "GET: $(curl -s -H "$AH" "$P/api/v1/push/subscription" | head -c 300)"
+[ "$(code -H "$BH" "$P/api/v1/push/subscription")" = 404 ] && ok "push_bob's token has none" || ko "push_bob's token sees a subscription"
+m_mention push_alice "first push $run"
+sink_waits "$sa_id" 1 \
+ && [ "$(sink_last "$sa_id" "r = d[-1]; p = r['payload'] or {}; print(r['ok'] and r['status'] == 201 and r['encoding'] == 'aes128gcm' and p.get('notification_type') == 'mention' and 'first push $run' in p.get('body', ''))")" = True ] \
+ && ok "mastouser's mention reaches the sink, aes128gcm, and decrypts to a mention" || ko "the push: $(scurl "$PS/subscriptions/$sa_id/received" | head -c 800)"
+[ "$(sink_last "$sa_id" "print(d[-1]['payload']['access_token'] == '$AT')")" = True ] && ok "it carries push_alice's own access token" \
+ || ko "the push's access_token is not hers"
+[ "$(sink_last "$sa_id" "r = d[-1]; v = r['vapid']; print(v['aud'] == 'https://pushsink.test' and v['sub'].startswith(('mailto:', 'https:')) and 'push_alice' not in v['sub'] and r['headers']['authorization_scheme'] == 'vapid' and int(r['headers']['ttl']) > 0 and r['headers']['urgency'] == 'normal')")" = True ] \
+ && ok "signed for the sink's origin by the server, TTL and Urgency set, no persona in the sub" || ko "the VAPID claims: $(sink_last "$sa_id" "print(d[-1]['vapid'], d[-1]['headers'])")"
+pushed_id=$(sink_last "$sa_id" "print(d[-1]['payload']['notification_id'])")
+[ "$(curl -s -H "$AH" "$P/api/v1/notifications/$pushed_id" | j "print(d['type'] == 'mention' and 'first push $run' in d['status']['content'])")" = True ] \
+ && ok "its notification_id is her notification" || ko "notification $pushed_id: $(curl -s -H "$AH" "$P/api/v1/notifications/$pushed_id" | head -c 200)"
+[ "$(sink_last "$sa_id" "p = d[-1]['payload']; print(p['icon'].startswith('https://privapub.test/') and 'mastouser' in p['title'] and p['preferred_locale'] != '')")" = True ] \
+ && ok "its icon goes through PrivaPub, its title names mastouser" || ko "icon and title: $(sink_last "$sa_id" "print(d[-1]['payload'])")"
+
+echo " alerts and policy"
+changed=$(curl -s -X PUT -H "$AH" "$P/api/v1/push/subscription" -d "data[alerts][favourite]=true")
+[ "$(echo "$changed" | j "print(d['alerts']['mention'] is False and d['alerts']['favourite'] is True and d['policy'] == 'all')")" = True ] \
+ && ok "PUT turns the mention alert off" || ko "PUT: $(echo "$changed" | head -c 300)"
+before=$(sink_count "$sa_id")
+m_mention push_alice "unwanted $run"
+arrives_unpushed "$AH" "unwanted $run" "$sa_id" "$before" && ok "with the alert off, the mention arrives unpushed" || ko "a mention was pushed with its alert off"
+alice_post=$(curl -s -X POST -H "$AH" "$P/api/v1/statuses" --data-urlencode "status=push_alice posts $run" -d visibility=public | j "print(d['uri'])")
+m_post=$(mcurl -H "$MH" "$M/api/v2/search?q=$alice_post&resolve=true&type=statuses" | j "print(d['statuses'][0]['id'])")
+mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/statuses/$m_post/favourite"
+sink_waits "$sa_id" $((before + 1)) && [ "$(sink_last "$sa_id" "print(d[-1]['ok'] and d[-1]['payload']['notification_type'])")" = favourite ] \
+ && ok "his favourite of her post is pushed" || ko "no favourite push: $(sink_last "$sa_id" "print(d[-1])")"
+curl -s -o /dev/null -X PUT -H "$AH" "$P/api/v1/push/subscription" -d "data[alerts][mention]=true" -d "data[policy]=followed"
+before=$(sink_count "$sa_id")
+m_mention push_alice "a stranger $run"
+arrives_unpushed "$AH" "a stranger $run" "$sa_id" "$before" && ok "policy=followed: mastouser, whom she does not follow, is not pushed" \
+ || ko "a non-followed sender was pushed"
+curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/follow"
+until_true 30 '[ "$(curl -s -H "$AH" "$P/api/v1/accounts/relationships?id[]=$m_on_p" | j "print(d[0][\"following\"])")" = True ]' \
+ && ok "push_alice follows mastouser" || ko "her follow was not accepted"
+m_mention push_alice "a friend now $run"
+sink_waits "$sa_id" $((before + 1)) && [ "$(sink_last "$sa_id" "print('a friend now $run' in d[-1]['payload']['body'])")" = True ] \
+ && ok "once followed, his mention is pushed" || ko "a followed sender was not pushed"
+
+echo " the aesgcm draft"
+sl=$(sink_new); sl_id=$(echo "$sl" | j "print(d['id'])")
+[ "$(subscribe "$AH" "$sl" false mention | j "print(d['standard'])")" = False ] && ok "push_alice subscribes again, standard=false" || ko "the legacy subscription"
+before=$(sink_count "$sa_id")
+m_mention push_alice "the draft $run"
+sink_waits "$sl_id" 1 \
+ && [ "$(sink_last "$sl_id" "r = d[-1]; print(r['ok'] and r['encoding'] == 'aesgcm' and r['headers']['authorization_scheme'] == 'WebPush' and 'p256ecdsa=' in r['headers']['crypto_key'] and r['headers']['encryption'].startswith('salt=') and r['payload']['notification_type'] == 'mention' and r['payload']['access_token'] == '$AT')")" = True ] \
+ && ok "aesgcm, with Encryption and Crypto-Key, decrypts to her mention" || ko "the aesgcm push: $(scurl "$PS/subscriptions/$sl_id/received" | head -c 800)"
+sleep 3
+[ "$(sink_count "$sa_id")" = "$before" ] && ok "the subscription it replaced gets nothing" || ko "the replaced subscription is still pushed"
+
+echo " siblings"
+sb=$(sink_new); sb_id=$(echo "$sb" | j "print(d['id'])")
+subscribe "$BH" "$sb" true mention policy=all >/dev/null
+alice_before=$(sink_count "$sl_id")
+m_mention push_alice "for alice only $run"
+sink_waits "$sl_id" $((alice_before + 1)); sleep 4
+[ "$(sink_count "$sb_id")" = 0 ] && ok "push_bob's subscription never gets push_alice's mention" || ko "a sibling's subscription got her push"
+m_mention push_bob "for bob only $run"
+sink_waits "$sb_id" 1 && [ "$(sink_last "$sb_id" "p = d[-1]['payload']; print(p['access_token'] == '$BT' and 'for bob only $run' in p['body'])")" = True ] \
+ && ok "push_bob's own mention, with his own token" || ko "push_bob's push: $(scurl "$PS/subscriptions/$sb_id/received" | head -c 400)"
+sleep 3
+[ "$(sink_count "$sl_id")" = "$((alice_before + 1))" ] && [ "$(sink_last "$sl_id" "print(all(r['payload']['access_token'] == '$AT' for r in d))")" = True ] \
+ && ok "push_alice gets nothing of his, and only her token" || ko "push_alice's subscription got push_bob's"
+[ "$(curl -s -X DELETE -H "$BH" "$P/api/v1/push/subscription")" = "{}" ] && ok "push_bob unsubscribes" || ko "push_bob's DELETE"
+
+echo " DELETE, 410, revocation"
+[ "$(curl -s -X DELETE -H "$AH" "$P/api/v1/push/subscription")" = "{}" ] && [ "$(code -H "$AH" "$P/api/v1/push/subscription")" = 404 ] \
+ && ok "DELETE: the subscription is gone" || ko "DELETE left it"
+before=$(sink_count "$sl_id")
+m_mention push_alice "after delete $run"
+arrives_unpushed "$AH" "after delete $run" "$sl_id" "$before" && ok "nothing is pushed after DELETE" || ko "pushed after DELETE"
+sg=$(sink_new); sg_id=$(echo "$sg" | j "print(d['id'])")
+subscribe "$AH" "$sg" true mention >/dev/null
+scurl -o /dev/null -X POST "$PS/subscriptions/$sg_id/answer" -H 'Content-Type: application/json' -d '{"status": 410}'
+m_mention push_alice "gone $run"
+sink_waits "$sg_id" 1 && until_true 15 '[ "$(code -H "$AH" "$P/api/v1/push/subscription")" = 404 ]' \
+ && ok "the sink answering 410 removes the subscription" || ko "after a 410: $(curl -s -H "$AH" "$P/api/v1/push/subscription" | head -c 200)"
+before=$(sink_count "$sg_id")
+m_mention push_alice "still gone $run"
+arrives_unpushed "$AH" "still gone $run" "$sg_id" "$before" && ok "and nothing more goes there" || ko "pushed after the 410"
+oauth=$("$here/../smoke/oauth.sh" "$P" "$ROOT_USER" "$ROOT_PASS" push_alice "read write follow push")
+RT=$(echo "$oauth" | cut -d' ' -f1); rcid=$(echo "$oauth" | cut -d' ' -f2); rcs=$(echo "$oauth" | cut -d' ' -f3)
+sr=$(sink_new); sr_id=$(echo "$sr" | j "print(d['id'])")
+subscribe "Authorization: Bearer $RT" "$sr" true mention >/dev/null
+curl -s -o /dev/null -X POST "$P/oauth/revoke" -d "token=$RT" -d "client_id=$rcid" -d "client_secret=$rcs"
+[ "$(code -H "Authorization: Bearer $RT" "$P/api/v1/push/subscription")" = 401 ] && ok "the revoked token is refused" || ko "the revoked token still works"
+m_mention push_alice "revoked $run"
+arrives_unpushed "$AH" "revoked $run" "$sr_id" 0 && ok "a revoked token's subscription is pushed nothing" || ko "pushed to a revoked token's subscription"
+[ "$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'db.PushSubscription.countDocuments()' 2>/dev/null | tail -1)" = 0 ] \
+ && ok "no subscription is left" || ko "subscriptions left: $(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'db.PushSubscription.countDocuments()' 2>/dev/null | tail -1)"
+
+curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/unfollow"
diff --git a/tools/pasture/sweep.sh b/tools/pasture/sweep.sh
index fb1dcb7..7c75b3d 100755
--- a/tools/pasture/sweep.sh
+++ b/tools/pasture/sweep.sh
@@ -6,11 +6,12 @@
# PrivaPub runs with SecureMode on, as in production, unless PRIVAPUB_ENV says otherwise; DOTNET and PASTURE_PORT reach
# run.sh as they are. Each batch's output is kept in out/sweep-.log.
# usage: tools/pasture/sweep.sh [A|B|C|D ...] (default: every batch, in that order)
-# A gts mastodon misskey sharkey akkoma, with relay, aoderelay and mailpit; scenarios: those five, threads followsync
+# A gts mastodon misskey sharkey akkoma, with relay, aoderelay, mailpit and pushsink; scenarios: those five, threads followsync
# pins moves relay (relay's covers both relays) accounts (mailpit's) instance (the server's own settings: it changes
# them all and puts them back) moderation (gts and mastodon; after instance, whose log check expects only the
# administrator's acts) emoji (gts, mastodon, misskey and akkoma; after moderation) featured (mastodon: featured
-# tags, notify, the direct stream, endorsements and notes per persona; after emoji)
+# tags, notify, the direct stream, endorsements and notes per persona; after emoji) push (mastodon and pushsink: Web
+# Push, encrypted both ways, alerts, policies, siblings, DELETE, 410 and revocation; after featured)
# B lemmy lemmy19 piefed mbin nodebb
# C peertube pixelfed vernissage wordpress ghost writefreely owncast castopod funkwhale
# D friendica mobilizon gancio bookwyrm smithereen mitra snac ktistec forte hubzilla
@@ -22,7 +23,7 @@ export PRIVAPUB_ENV=${PRIVAPUB_ENV:-Federation__SecureMode=true}
batch_peers() {
case "$1" in
- A) echo "gts mastodon misskey sharkey akkoma relay aoderelay mailpit" ;;
+ A) echo "gts mastodon misskey sharkey akkoma relay aoderelay mailpit pushsink" ;;
B) echo "lemmy lemmy19 piefed mbin nodebb" ;;
C) echo "peertube pixelfed vernissage wordpress ghost writefreely owncast castopod funkwhale" ;;
D) echo "friendica mobilizon gancio bookwyrm smithereen mitra snac ktistec forte hubzilla" ;;
@@ -30,7 +31,7 @@ batch_peers() {
}
batch_scenarios() {
case "$1" in
- A) echo "gts mastodon misskey sharkey akkoma threads followsync pins moves relay accounts instance moderation emoji featured" ;;
+ A) echo "gts mastodon misskey sharkey akkoma threads followsync pins moves relay accounts instance moderation emoji featured push" ;;
*) batch_peers "$1" ;;
esac
}
diff --git a/tools/smoke/mastodon-api.sh b/tools/smoke/mastodon-api.sh
index 03a3b17..e670b3f 100755
--- a/tools/smoke/mastodon-api.sh
+++ b/tools/smoke/mastodon-api.sh
@@ -6,12 +6,19 @@ BASE="${1:?base url}"; TOKEN="${2:-}"
fail() { echo "::error::$*"; exit 1; }
json() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
+# the version clients branch on (Mastodon invariant 6): Mastodon 4.5's, then PrivaPub's release
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"
+case "$version" in "4.5.0 (compatible; PrivaPub"*) ;; *) fail "instance v1 says version '$version', not 4.5.0 (compatible; PrivaPub ...)" ;; esac
+v2=$(curl -fsS "$BASE/api/v2/instance") || fail "instance v2"
+echo "$v2" | json "assert d['configuration']['statuses']['max_characters'] > 0" || fail "instance v2"
+echo "$v2" | json "assert d['version'] == '$version' and d['api_versions']['mastodon'] == 7" || fail "instance v2's version or api_versions"
+vapid=$(echo "$v2" | json "import base64; k = d['configuration']['vapid']['public_key']; assert len(base64.urlsafe_b64decode(k)) == 65; print(k)") \
+ || fail "instance v2 gives no VAPID key (configuration.vapid.public_key)"
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'])")
+[ "$(echo "$app" | json "print(d['vapid_key'])")" = "$vapid" ] || fail "the app's vapid_key is not the instance's VAPID key"
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")