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

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

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

No files matched your search

+134 -11
View File
@@ -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][<alert>]`, `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|<subscription>|<notification>`, 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=<jwt>, k=<key>`. Any other gets the draft Mastodon still sends by default, `aesgcm` (two bytes of padding length
first) with `Encryption: salt=…`, `Crypto-Key: dh=<our key>;p256ecdsa=<the VAPID key>` and `Authorization: WebPush
<jwt>`. 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@<host>`
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 <release>)` (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 <release>)`** 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