Document the PrivaPub wiring
CLAUDE.md: the two APIs and their credentials, the services, where a post is placed, the feed page and the globe, nerd stats, running against a local PrivaPub or the pasture, and that resx keys must not differ only by case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
827ecb770e
commit
d552baa578
1 file changed
+88
-48
@@ -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<WebResult>` 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<HttpContent>` (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<WebResult>`, `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 `<script>` tags in `wwwroot/index.html`.
|
||||
- The other scripts (IndexedDB, HeadElement, rxjs) 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`.
|
||||
|
||||
**PWA.** Published builds use `wwwroot/service-worker.published.js`, and `service-worker.js` is the dev version.
|
||||
|
||||
@@ -223,9 +260,9 @@ so follow them in every change.
|
||||
getters and no `?.` chains over state that can't be null:
|
||||
|
||||
```csharp
|
||||
List<Message> Messages { get; set; } = []; // DO
|
||||
@Messages.Count // DO
|
||||
@(Messages?.Count ?? 0) // DON'T
|
||||
List<FeedPost> posts = []; // DO
|
||||
@posts.Count // DO
|
||||
@(posts?.Count ?? 0) // DON'T
|
||||
```
|
||||
|
||||
`= ""` is allowed only as a documented exception, with the reason in a comment (e.g. a decorative image's `Alt`).
|
||||
@@ -255,22 +292,22 @@ protected override async Task OnInitializedAsync()
|
||||
`result.IsInvalid` instead:
|
||||
|
||||
```csharp
|
||||
var result = await MessagesService.DeleteMessage(message);
|
||||
var result = await StatusService.Delete(Status.Id);
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
Messages.Remove(message);
|
||||
Toast.AddResponseSuccess(Localizer["Message deleted."]);
|
||||
await OnDeleted.InvokeAsync(Post);
|
||||
```
|
||||
|
||||
**Client services** follow b2b.next's two patterns (see its `StoreService`):
|
||||
- Create `var result = new WebResult();`, call `httpService` inside a try/catch, and on a non-success status
|
||||
`result = await response.Content.ReadWebResult(response.StatusCode, localizer, ct)`. Log with `ILoggingService`.
|
||||
- Return `result` (mutations) or `[]` (lists), and set `result.Data` on success.
|
||||
- POST bodies are JSON payloads.
|
||||
- POST bodies are JSON payloads for `/clientapi` and the Mastodon API (snake_case DTOs), forms for `/oauth`.
|
||||
|
||||
**In-page errors.** `ErrorViewer` takes `AdditionalError` for one message and `AdditionalErrors` for a list. Never
|
||||
join a list into one string with `<br/>` or `\n`.
|
||||
@@ -279,11 +316,13 @@ join a list into one string with `<br/>` or `\n`.
|
||||
- `autocomplete="off"` for search, filter and free text;
|
||||
- `new-password` when setting a password;
|
||||
- `one-time-code` for OTP (DOtp sets it);
|
||||
- omit it for login credentials;
|
||||
- `username` and `current-password` on the login form, so password managers fill it;
|
||||
- inside `Components/Daisy`, attributes pass through from the caller.
|
||||
|
||||
**Localization:**
|
||||
- Keys are English source strings. Grep both resx files for an existing key before adding one; match keys exactly.
|
||||
- Never add a key that differs from an existing one only by case ("users" next to "Users"): resource lookups collide
|
||||
on them and one of the two silently shows the other's text. Reuse the existing key.
|
||||
- Use `Localizer[key, args]`, never `string.Format(Localizer[key], …)`. Don't write `.Value`.
|
||||
- Enum members shown through `Localizer[value.ToString()]` are keys too: when you add a member, add its key.
|
||||
- Language names in the language picker stay in their own language.
|
||||
@@ -292,13 +331,14 @@ join a list into one string with `<br/>` or `\n`.
|
||||
and sizes come from `GetPassedTime` / `GetFileSize` (`Extensions/GenericExtensions.cs`). No inline format strings for
|
||||
display.
|
||||
|
||||
**JSON.** Register every type that crosses HTTP or goes into localStorage in `ClientJsonContext.cs`. Keep it
|
||||
camelCase: `theme.js` reads `pageSettings` straight from localStorage.
|
||||
**JSON.** Register every `/clientapi` or localStorage type in `ClientJsonContext.cs` (camelCase: `theme.js` reads
|
||||
`pageSettings` straight from localStorage), every Mastodon API type in `MastodonJsonContext.cs` (snake_case).
|
||||
|
||||
**Wiring a feature to PrivaPub:**
|
||||
1. Add the request/response DTOs to `PrivaPub.ClientModels` in the SocialPub repo: `View*` for responses, `*Request`
|
||||
for requests, `*Form` for forms with validation. Lists `= []`, no string defaults.
|
||||
2. Register them in `ClientJsonContext`.
|
||||
1. For `/clientapi`, add the request/response DTOs to `PrivaPub.ClientModels` in the SocialPub repo: `View*` for
|
||||
responses, `*Request` for requests, `*Form` for forms with validation. Lists `= []`, no string defaults. For the
|
||||
Mastodon API, add the shape to `Models/Mastodon/`.
|
||||
2. Register them in `ClientJsonContext` or `MastodonJsonContext`, and the route in `Models/APIs.cs`.
|
||||
3. Add a client service method that calls `IHttpService` against the server's route, following the patterns above.
|
||||
4. Wire the page with early returns, toasts and/or an `ErrorViewer`.
|
||||
|
||||
@@ -323,8 +363,8 @@ host. The steps:
|
||||
5. Check that `build.json` serves the new commit and that a deep client route returns 200 (the SPA fallback). Otherwise,
|
||||
roll back to the snapshot.
|
||||
|
||||
The nginx vhost and headers are in `deploy/nginx/`, 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.
|
||||
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.
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
Reference in new issue
Block a user