diff --git a/CLAUDE.md b/CLAUDE.md index 8ad0f6c..7590083 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `