R9 Web Push in decePub: one subscription per persona, each with its own token and registration
Build / Build (push) Successful in 56s
Build / Build (push) Successful in 56s
Settings gets "Notifications on this device" (PushEditor): personas, alerts in four groups, policy, a test
notification, after a one-time notice of what the push service learns. Each persona ticked gets its own service
worker registration (push-worker.js at push/{slot}/), so its own endpoint and keys, subscribed with its own token
(IHttpService's *As); nothing PrivaPub stores ties two personas together and the worker never reads the payload's
token. PushService follows the tokens (re-subscribing after a renewal), reconciles at every session start, turns
off before sign-out and drops deleted or suspended personas. A click opens that persona's notifications; the app
badge counts the unread ones. Exchanges now ask for the push scope.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
d33697e3fc
commit
ab17959ed4
29 files changed
+2047
-67
No files matched your search
@@ -225,7 +225,11 @@ all of them in every theme. When you add or change one:
|
||||
answered `FailureCodes.PersonaRefused` without asking PrivaPub, until it is picked anew or the session is read again)
|
||||
and `CascadingState` reads the personas again and moves to another usable one, or to none, saying why once;
|
||||
- `ProcessError` / `ProcessWarning`, which log through `ILoggingService` and show a toast;
|
||||
- `LogFromJs`, which JS calls back into through a `DotNetObjectReference`.
|
||||
- `LogFromJs`, which JS calls back into through a `DotNetObjectReference`;
|
||||
- Web Push's bookkeeping (see **Web Push**): `PushService.Reconcile` once the session is read (`Forget` when it isn't
|
||||
signed in), `TurnOff` before `SignOut`, `Drop` for a persona deleted or refused, the app's badge cleared with the session,
|
||||
and a notification's click: `?push={slot}` read as the app starts (its persona is acted as) and `PushOpened`, which
|
||||
`main.js` calls when the app is open.
|
||||
|
||||
**Components and pages** inherit `LocalizableComponentBase`, the equivalent of b2b.next's `WidmannComponentBase`. It
|
||||
supplies:
|
||||
@@ -256,6 +260,10 @@ Never redeclare the base's members in a component.
|
||||
- Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses
|
||||
them; `GetAnon` adds them when there are some; `*TotallyAnon` send none (the login and the password recovery must
|
||||
not carry a JWT: PrivaPub redirects them).
|
||||
- `GetAs` / `PostAs` / `PutAs` / `DeleteAs` send a Mastodon API request as a given persona of the login, whichever the
|
||||
session acts as: with that persona's own token (`TokenAuthStateProvider.GetPersonaToken(personaId)`), exchanged when
|
||||
missing and kept without making the persona current (`SetPersonaToken`), never another persona's. Web Push uses them.
|
||||
`TokenAuthStateProvider.PersonaTokenChanged` tells when a persona's token is replaced or forgotten.
|
||||
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
|
||||
- Each request has its own timeout (`Measure`): 100 s, or 10 minutes for a multipart body (an upload); the
|
||||
`HttpClient`'s own is off.
|
||||
@@ -273,7 +281,10 @@ Never redeclare the base's members in a component.
|
||||
each persona the client uses gets its own Mastodon token in exchange for the JWT (PrivaPub's `PersonaExchange`,
|
||||
RFC 8693 on `/oauth/token`, first-party client id `AppConfiguration:ClientId`). `AuthData` keeps the JWT and the
|
||||
persona tokens; logout revokes every persona token, ends this device's session (`/clientapi/user/logout`), then
|
||||
forgets the session.
|
||||
forgets the session. The exchange asks for `read write follow push` (`APIs.OAuth.Scopes`; PrivaPub grants `push` since
|
||||
R9, and one from before R9 refuses the whole exchange, since its first-party client lacks the permission: PrivaPub's
|
||||
R9 is deployed first); a token exchanged before that is refused a push subscription (403, "outside the authorized
|
||||
scopes"), and `AuthService.RenewPersonaToken` revokes and forgets it, so the next request exchanges one with `push`.
|
||||
- **Sessions:** "Sign out everywhere" (`AuthService.LogoutEverywhere`, `/clientapi/user/logout/everywhere`) ends every
|
||||
session of the login, this one too, and PrivaPub revokes every persona's token with them, so the client only forgets
|
||||
its own and signs out. A password change, and turning two-factor sign-in on, end every other session and revoke every
|
||||
@@ -322,13 +333,16 @@ Never redeclare the base's members in a component.
|
||||
`/clientapi/admin`), `AboutService` (what the server says of itself to anyone: the instance API, its documents
|
||||
and published domain blocks, never with credentials), `AnnouncementService` (`/api/v1/announcements`: dismissing and
|
||||
reacting), `EmojiService` (the server's custom emoji: `/api/v1/custom_emojis` for everyone, cached, and
|
||||
`/clientapi/admin/emoji` for administrators; see **Custom emoji**), `MediaService` (uploads, and deleting those no post holds), `GroupService`, `InstanceService` and
|
||||
`/clientapi/admin/emoji` for administrators; see **Custom emoji**), `MediaService` (uploads, and deleting those no post holds), `PushService` (this device's Web Push subscriptions, one per
|
||||
persona with its own token; see **Web Push**), `GroupService`, `InstanceService` and
|
||||
`ProvenanceService`.
|
||||
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction, poll vote
|
||||
or downvote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in
|
||||
localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice,
|
||||
with its own `PublicAct`: joining an event (owner decision 2026-10-05) is asked once more, since the event's server
|
||||
shows who takes part. An event card shows the persona's participation (`privapub.event.participation`: pending,
|
||||
shows who takes part; this device's first Web Push subscription (`PublicAct.Push`, asked once for the device,
|
||||
`PublicActionNotices.PushTold`) says what the browser's push service learns (see **Web Push**). `data-public-notice`
|
||||
names which: `reaction`, `participation` or `push`. An event card shows the persona's participation (`privapub.event.participation`: pending,
|
||||
accepted, rejected) with Join or Leave, except for events by invitation or taken on another site (`join_mode`).
|
||||
- **What a card shows of other platforms:** custom emoji (`Helpers/CustomEmojis.cs`: a post's or account's `:shortcode:`
|
||||
in its text, name, warning, bio and fields, as the picture the server listed, never inside a tag; this server's own
|
||||
@@ -474,7 +488,8 @@ Never redeclare the base's members in a component.
|
||||
(`ReadingPreferencesEditor`, the Reading section: `data-preference-expand-media`, a select of default, show_all or
|
||||
hide_all, `data-preference-expand-spoilers`, a toggle, `data-preferences-save`, `data-preferences-reset`; `GET`/`POST
|
||||
/clientapi/avatar/private/preferences` with `PersonaPreferencesForm`, since Mastodon's API only reads them at
|
||||
`/api/v1/preferences`; see **Reading preferences**), its mutes,
|
||||
`/api/v1/preferences`; see **Reading preferences**), this device's notifications (`PushEditor`, the "Notifications on
|
||||
this device" section, `data-settings-push`; see **Web Push**), its mutes,
|
||||
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows (with their boosts, notify and languages
|
||||
columns, each imported only when the file has it), lists, blocks, mutes, blocked servers and bookmarks exported and
|
||||
imported as the CSV files Mastodon writes, so a persona moves between servers. An import
|
||||
@@ -649,6 +664,58 @@ Never redeclare the base's members in a component.
|
||||
notification is dismissed from its line (`data-notification-dismiss`,
|
||||
`TimelineService.DismissNotification`: `/api/v2/notifications/:group_key/dismiss`, its whole group, `ungrouped-{id}` for
|
||||
one alone), and the page takes that item out (`Content.OnDismissed`).
|
||||
- **Web Push** (PrivaPub's R9, Mastodon's `/api/v1/push/subscription`; `Services/PushService.cs`, `wwwroot/js/push.js`,
|
||||
`wwwroot/push-worker.js`, `Components/Settings/PushEditor.razor`). Notifications reach the device while decePub is closed.
|
||||
- **One subscription per persona, each in its own registration.** PrivaPub keeps one subscription per access token, so
|
||||
per persona, and pushes it that persona's notifications alone. Each persona ticked on this device gets a service worker
|
||||
registration of its own: `push-worker.js` at the scope `push/{slot}/`, the slot a random name this device keeps
|
||||
(`PushDevice` in localStorage: the personas with their slots and PrivaPub's subscription ids, the alerts, the policy;
|
||||
never a token). So each persona's subscription has its own endpoint and keys: PrivaPub stores nothing that ties two
|
||||
personas of one login together (one endpoint shared by them would), and the worker knows its persona from its scope.
|
||||
The app's own workers (`service-worker.js`, `service-worker.published.js`) receive no pushes; one `push-worker.js`
|
||||
serves both builds and is never precached.
|
||||
- **Each persona's own token.** Its subscription is made (`POST`), read (`GET`), changed (`PUT`, `data[alerts]` and
|
||||
`data[policy]`) and deleted (`DELETE`) as that persona (`IHttpService`'s `*As`), whichever persona the session acts
|
||||
as; JSON, which PrivaPub flattens to Mastodon's `subscription[endpoint]`, `subscription[keys][p256dh]`,
|
||||
`subscription[keys][auth]`, `subscription[standard]` (true: RFC 8291's aes128gcm, unless the browser lists only
|
||||
aesgcm), `data[alerts][…]`, `data[policy]`. The browser subscribes with the server's key, the instance API's
|
||||
`configuration.vapid.public_key` (`InstanceV2.Configuration.Vapid`, read through `AboutService`).
|
||||
- **Privacy.** The payload is encrypted to the persona's browser subscription alone, so the push service (Google's,
|
||||
Mozilla's, Apple's…) learns only that something arrived, when and how big, never the words, the sender or the persona.
|
||||
The payload is Mastodon's and carries the subscription's access token: `push-worker.js` never reads, keeps, shows or
|
||||
passes it on; it reads `title`, `body`, `icon`, `notification_id` (the notification's `tag`, so a repeat replaces it),
|
||||
`notification_type` and `preferred_locale`. Before this device's first subscription, the one-time notice says so
|
||||
(`PublicAct.Push`).
|
||||
- **Lifecycle.** A subscription ends with its token on PrivaPub, so `PushService` follows the tokens: a persona whose
|
||||
token is replaced or forgotten (a 401 exchanged again, a password change, two-factor turned on, a scope renewal) is
|
||||
subscribed again with the new one two seconds later (`PersonaTokenChanged`); every start of the session reconciles
|
||||
(`Reconcile`: registrations it keeps no persona for are unregistered; permission withdrawn, or push gone from the
|
||||
browser, unsubscribes everything; a persona deleted or suspended is dropped; each other one is checked with `GET` and
|
||||
`PUT`/`POST` only when PrivaPub's differs, e.g. a new endpoint after the worker's `pushsubscriptionchange`). Signing out
|
||||
`DELETE`s each persona's subscription with its own token, then unregisters (`TurnOff`, in `CascadingState.SignOut`);
|
||||
"Sign out everywhere", deleting the login, and a session that ended on its own only unregister (`Forget`: PrivaPub
|
||||
ended them with the tokens). A persona deleted (`CascadingState.PersonaDeleted`) or refused a token is `Drop`ped.
|
||||
- **A click** closes the notification and opens decePub on that persona's Notifications tab: an open decePub tab is
|
||||
focused and posted `{type: "decepub-push-open", slot}` (the worker can't navigate a tab another worker controls), which
|
||||
`main.js` hands to `CascadingState.PushOpened` (it switches to the persona, then navigates to `?notifications=1`);
|
||||
otherwise a new tab opens at `/?notifications=1&push={slot}` (CascadingState acts as the slot's persona when the session
|
||||
is read; `Index.razor` opens the Notifications tab and takes the question out of the address with `replace`).
|
||||
- **The badge.** `Index.razor` sets the installed app's badge to the persona's unread notifications whenever the count
|
||||
changes, and clears it when they are read (`AppStatusService.SetAppBadge` / `ClearAppBadge`; `main.js` does nothing
|
||||
where the Badging API is missing); a push sets a plain dot until the app counts; the session's end clears it.
|
||||
- **Settings** (`PushEditor`, `data-push-editor`, `data-push-state` `subscribed|off|denied|unsupported`): on or off for
|
||||
this device (`data-push-toggle="on|off"`: the notice, then the browser's permission asked from the click, then each
|
||||
ticked persona subscribed; off `TurnOff`s); the personas of the login (`input[data-push-persona="{userName}"]`, a
|
||||
suspended one disabled; the current one ticked while none is subscribed); the alerts in four groups (mentions and
|
||||
posts: `mention`, `status`, `quote`, `update`, `poll`; reactions: `favourite`, `reblog`, `pleroma:emoji_reaction`;
|
||||
follows: `follow`, `follow_request`; the staff: `moderation_warning`; each `input[data-push-alert="{alert}"]`; those
|
||||
PrivaPub keeps but never fires, `quoted_update`, `severed_relationships`, `annual_report` and the admin ones, are not
|
||||
offered and stay off); whose (`select[data-push-policy]`: all, followed, follower, none); a test notification shown by
|
||||
the persona's registration alone, nothing sent to the server (`data-push-test`); `data-push-reset`; `data-push-save`
|
||||
(while on: subscribes the personas newly ticked, unsubscribes the others, `PUT`s the alerts and policy). Unsupported
|
||||
(`data-push-unsupported`: `server` without a VAPID key, `insecure`, `ios-install` for an iPhone or iPad whose decePub
|
||||
isn't added to the Home Screen and opened from there, `unsupported`) says why; blocked notifications say how to allow
|
||||
them (`data-push-denied`).
|
||||
- **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a
|
||||
time (`scheduled_at`, at least five minutes ahead; located and group posts have none); the page moves or drops them.
|
||||
- **Explore** (`Pages/Explore.razor` at `/explore`): trending hashtags, posts and links (PrivaPub's, from public posts
|
||||
@@ -750,7 +817,7 @@ Never redeclare the base's members in a component.
|
||||
key needs its `public static string` property in `ErrorMessages.Designer.cs` too, or validation throws at runtime.
|
||||
|
||||
**Client storage** is localStorage (Blazored.LocalStorage, camelCase JSON): `AuthData`, `PublicCacheData`, `FeedFilter`,
|
||||
the server cache `ServerInfos` and the server's custom emoji `CustomEmojiCache`, keyed by `nameof(Type)`. There is no IndexedDB: `ILoggingService` writes to the
|
||||
the server cache `ServerInfos`, the server's custom emoji `CustomEmojiCache` and this device's Web Push `PushDevice`, keyed by `nameof(Type)`. There is no IndexedDB: `ILoggingService` writes to the
|
||||
browser console only, and `main.js` deletes the database (`data`) where the client once kept its log, wherever a browser
|
||||
still holds it.
|
||||
|
||||
@@ -764,10 +831,17 @@ it: the root's name, its policies as claims, and the current persona. Pages that
|
||||
- Each helper reports its errors back through `window.logFromJs`.
|
||||
- The other scripts (`js/theme.js`, HeadElement's, `vendor/cally.js`, `js/blurhash.js`) are loaded by `<script>` tags in
|
||||
`wwwroot/index.html`; the feed's
|
||||
modules (`js/feed.js`, `js/globe.js`) are imported on demand through `IJSObjectReference`.
|
||||
modules (`js/feed.js`, `js/globe.js`) are imported on demand through `IJSObjectReference`, as is Web Push's (`js/push.js`,
|
||||
by `PushService`: support and permission, a persona's registration and subscription made, read and dropped, the slots
|
||||
held, a test notification). `main.js` listens for the personas' push workers (`navigator.serviceWorker` messages, a
|
||||
notification's click while the app is open) and calls `CascadingState.PushOpened`.
|
||||
|
||||
**PWA.** Published builds use `wwwroot/service-worker.published.js`, and `service-worker.js` is the dev version. The
|
||||
published worker names its cache after the assets manifest's version, so each publish drops the previous cache.
|
||||
published worker names its cache after the assets manifest's version, so each publish drops the previous cache. Web Push
|
||||
has workers of its own, one registration per persona subscribed (`wwwroot/push-worker.js` at `push/{slot}/`, the same file
|
||||
in both builds, kept out of the precache; see **Web Push**): `push` shows the notification, `notificationclick` opens
|
||||
the app on that persona's notifications, `pushsubscriptionchange` subscribes again with the same key. They control no
|
||||
page and have no fetch handler.
|
||||
|
||||
## Development rules
|
||||
|
||||
@@ -900,7 +974,7 @@ host. The steps:
|
||||
roll back to the snapshot.
|
||||
|
||||
The nginx vhost and headers are in `deploy/nginx/` (it sends `X-Robots-Tag: noindex, nofollow`), and
|
||||
`deploy/max/setup.sh` is the one-time root setup on the server. If you add a file that must never be cached, add it to the `expires -1` locations in the vhost.
|
||||
`deploy/max/setup.sh` is the one-time root setup on the server. If you add a file that must never be cached, add it to the `expires -1` locations in the vhost (the workers `service-worker.js` and `push-worker.js` are there).
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
Reference in new issue
Block a user