diff --git a/CLAUDE.md b/CLAUDE.md index 31fefa2..6599b39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# CLAUDE.md +# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. @@ -9,12 +9,10 @@ owner's ActivityPub server, whose repo is `thepra/SocialPub`. It is deployed as https://decepub.thepra.dev, and its API base address is `https://privapub.thepra.dev` (`AppConfiguration:ApiBaseAddress` in `wwwroot/appsettings.json`). -**It is still a prototype and is not connected to the server:** -- The feed and threads come from `Helpers/Faker.cs`: `Storage.GetMessages()` returns the seed feed while IndexedDB is - empty, and `Faker.Thread` builds the messages around an expanded one. -- `MessagesService` has the real `Task` signatures but builds its results in memory. -- `HttpService` is registered but nothing calls it yet. -- The nginx vhost sends `X-Robots-Tag: noindex` while the site shows mock posts. +It talks to PrivaPub through both of its APIs with one sign-in (see **PrivaPub wiring** below). The feed page has three +columns: navigation, the feed with a tab per kind of feed, and a 3D globe that follows the posts in view. Nerd stats sit +in tooltips wherever there is data. Development builds point at a local PrivaPub on `http://127.0.0.1:6970` +(`wwwroot/appsettings.Development.json`, never published). `Docs/Insomnia_APIs.json` is an old API collection for a localhost backend. Don't treat it as PrivaPub's API. @@ -27,8 +25,15 @@ checked out next to this repo. CI clones it there. That project supplies: - the `FieldsNameResource` and `ErrorsResource` localization resources. Before wiring anything to the API, read `../SocialPub/CLAUDE.md`. PrivaPub's route names (`/peasants/...`, `/mouth`, -`/anus`, ...) are deliberate. Never "correct" them. The intended API surface is the Mastodon client API plus PrivaPub's -private `/clientapi`. +`/anus`, ...) are deliberate. Never "correct" them. The API surface is the Mastodon client API plus PrivaPub's private +`/clientapi` and `/api/privapub/v1`. + +To run against a local PrivaPub: start a mongod, run SocialPub's `PrivaPub` with the Development command in its +CLAUDE.md on `127.0.0.1:6970` (`Registrations__Mode=Open` lets `/clientapi/user/signup` create a root), then create +personas with `/clientapi/avatar/private/insert`. Federated posts come from SocialPub's pasture +(`tools/pasture/run.sh up gts akkoma`, then `tools/pasture/interop.sh gts akkoma`), whose API is `127.0.0.1:6971`. +Neither DB-IP nor PrivaPub locate private addresses, so for the globe set `RemoteInstance.Geo` in mongo and +`Statistics:Geo:Self` in PrivaPub's configuration. ## Commands @@ -126,7 +131,9 @@ all of them in every theme. When you add or change one: - `PublicCacheData` (page settings: theme colours, dark/light/system theming, language), persisted in localStorage; - the theme, applied through `AppStatusService.ApplyTheme` (`wwwroot/js/theme.js`); - `IsOnline` / `IsOffline`, polled every 10 seconds; -- `User`, which is `Faker.CurrentUser` until the login is wired; +- the session: `IsSignedIn`, `Personas` (the root's), `PersonaId` and `Persona` (its Mastodon `Account`), with + `LoadSession`, `SwitchPersona` and `SignOut`. A persona change re-renders every subscriber; pages compare + `PersonaId` and reload what belongs to the persona. The JWT is renewed hourly when needed; - `ProcessError` / `ProcessWarning`, which log through `ILoggingService` and show a toast; - `LogFromJs`, which JS calls back into through a `DotNetObjectReference`. @@ -137,8 +144,7 @@ supplies: - `AfterRenderAsyncJobs`, run once after the next render; - `WhenOnline` / `StartOnlinePolling`. -`PagesBase` is an empty subclass of it, and only `Authentication.razor` uses it. Never redeclare the base's members in a -component. +`PagesBase` is an empty subclass of it that nothing uses. Never redeclare the base's members in a component. **UI feedback.** - `Services/ToastService.cs` is the snackbar: the SUtility extensions `AddResponseError(WebResult)` (silent on @@ -148,19 +154,55 @@ component. - `Components/ErrorViewer.razor` shows in-page errors: the cascading `EditContext`'s validation messages, `AdditionalError` and `AdditionalErrors`. -**HTTP (`Services/IHttpService.cs`)**, registered but not called yet, since the app still runs on mock data: -- Auth levels: `Get` / `Post` / `Delete` need a valid token and sign out on 401; `GetAnon` / `PostAnon` add the token - if there is one; `*TotallyAnon` send none. +**HTTP (`Services/IHttpService.cs`).** +- Routes are constants in `Models/APIs.cs` (b2b.next naming: `GET_`/`POST_`/`DELETE_`, `_M_` for routes built from + parameters), relative to `ApiBaseAddress`. +- The route picks the credentials: `clientapi/` sends the root's JWT, `api/` the current persona's Mastodon token + (exchanged when missing, exchanged once more and retried when PrivaPub refuses it), `oauth/` nothing. +- It also picks the JSON: `SUtility.MastodonSerializer` (snake_case, `MastodonJsonContext`) for `api/` and `oauth/`, + `SUtility.DefaultSerializer` (camelCase, enums by name like PrivaPub, `ClientJsonContext`) for `clientapi/`. A payload + can be an object (JSON), name/value pairs (a form) or a `Func` (multipart), built once per attempt. +- Auth levels: `Get` / `Post` / `Delete` need credentials and end the session when PrivaPub refuses them; `GetAnon` / + `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT: PrivaPub + redirects it). - `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes. - Failures never throw. They come back as responses carrying an invalid `WebResult`, with an `ErrorCode` from `Models/FailureCodes.cs`; a cancelled request comes back as HTTP 410. -- Read failures with `ReadWebResult` and bodies with `DefaultReadFromJsonAsync` (`Extensions/ExtensionMethods.cs`). -- JSON goes through `SUtility.DefaultSerializer`, which consults `ClientJsonContext` first. +- Read failures with `ReadWebResult` (it understands `WebResult`, validation ProblemDetails and Mastodon's + `{"error": ...}`) and bodies with `DefaultReadFromJsonAsync` / `MastodonReadFromJsonAsync`; a body that doesn't fit + comes back as the default and is written to the browser console. `PageCursors()` reads `max_id`/`min_id` from `Link`. +- Every request PrivaPub answers is recorded in `RequestStats` (method, route template, status, duration, size, rate + limit headers; never a body or a token), for the nerd stats. - PrivaPub's `WebResult` gets `IsInvalid` from `Extensions/WebResultExtensions.cs`, a C# 14 extension property. -**Mock data.** `MessagesService` has the real signatures (`Task`, `Data` = the updated entity), but builds -its results in memory. `Helpers/Faker.cs` holds the current user, the seed feed and the thread around an expanded -message. +**PrivaPub wiring.** +- **One sign-in** (`Services/AuthService.cs`, `Pages/Login.razor`): the root signs in on `/clientapi/user/login`, then + 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, then forgets the session and IndexedDB. +- **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`; + circles are read as ids from `/clientapi` and hydrated through `/api/v1/statuses?id[]=`; located posts come from + `/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete, + threads, mute, block; located and group posts go through `/clientapi/post/insert`), `MediaService` (uploads), + `GroupService`, `InstanceService` and `ProvenanceService`. +- **Mastodon DTOs** are client-side, in `Models/Mastodon/` (snake_case). `/clientapi` DTOs come from + `PrivaPub.ClientModels`. +- **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`), + its event's venue, the reader for a located post, its author's server (PrivaPub's public projection: 0.1° for servers + with at least 10 users, else the country, drawn at `Helpers/CountryPoints.cs`), or nowhere (a CDN-fronted server). + Server descriptions are cached a day in localStorage (`ServerInfos`). PrivaPub's own host is the signed-in persona's + account host, since the API may be reached under another name. +- **The feed page** (`Pages/Index.razor`): `FeedTabs`, `Content` (the card, which does its own actions), `Composer`, + `GlobePanel`. Older pages load as the end comes near (`wwwroot/js/feed.js`); newer ones are polled every 45 s while + the page is visible (PrivaPub has no streaming) and wait behind a "new" button. +- **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in + `wwwroot/vendor/globe/` with its textures (`SOURCES.md` has versions, licences and hashes; the service worker never + precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an arc to a hovered + post, follows the theme (day/night texture), honours `prefers-reduced-motion` and pauses while hidden. The posts' + places reach it in one call per feed change; cards are matched by `data-post-id`. +- **Nerd stats** (`Components/Nerd/NerdTip.razor`, rows from `NerdRow`): a post's ids, times and provenance (read on + hover), its author and server, its place, media and counts; the feed's paging and last request; the globe's camera, + points and frame rate. **Localization (`Services/CoalescingStringLocalizer.cs`).** - Lookup order: this repo's `Resources/AllStrings.resx` (+ `.it.resx`), then `FieldsNameResource`, then @@ -171,28 +213,23 @@ message. needs its `public static string` property in `ErrorMessages.Designer.cs` too, or validation throws at runtime. **Client storage** comes in two layers: -- **localStorage** (Blazored.LocalStorage, camelCase JSON) holds `AuthData` and `PublicCacheData`, keyed by - `nameof(Type)`. +- **localStorage** (Blazored.LocalStorage, camelCase JSON) holds `AuthData`, `PublicCacheData`, `FeedFilter` and the + server cache `ServerInfos`, keyed by `nameof(Type)`. - **IndexedDB** (DnetIndexedDb, database `data`) is reached through `IStorage`. Its schema is declared in `GenericExtensions.AddIndexedDb()`, and you must bump `WithVersion` when you change it. -- The schema is at version 2, which added the `ClientLogs` store that `IStorage.AddLog` writes. Before it, every log - write failed silently. +- The schema is at version 3: the `ClientLogs` store that `IStorage.AddLog` writes (version 2 added it; version 3 left + the mock feed's `Message` store out). -**Auth.** -- `TokenAuthStateProvider` builds the auth state from the `AuthData` token in localStorage. -- `SetToken` is mostly commented out, so there is no real login flow yet. -- `Pages/Logout.razor` signs out through `TokenAuthStateProvider.LogoutAsync`, which clears the token and the local - database. -- `AddApiAuthorization`, `Pages/Authentication.razor` (`/authentication/{action}`) and `RedirectToLogin` are leftovers - from the Blazor OIDC template. That route's `RemoteAuthenticatorView` throws `InvalidCastException`, because the - registered `AuthenticationStateProvider` is `TokenAuthStateProvider`. The Login links that point there need a real - login page first. +**Auth.** `TokenAuthStateProvider` holds `AuthData` (localStorage, cached in memory) and builds the auth state from +it: the root's name, its policies as claims, and the current persona. Pages that need a session carry +`[Authorize]`; `RedirectToLogin` sends the reader to `/login?returnUrl=…`. **JS interop.** - `wwwroot/main.js` defines `window.*` helpers. `Services/AppStatusService.cs` calls them, mostly through the synchronous `IJSInProcessRuntime`. - Each helper reports its errors back through `window.logFromJs`. -- The other scripts (IndexedDB, HeadElement, auth, rxjs) are loaded by `