e2f61ede566107236889c4312fe25fb12f10d0a4
6
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8f25bf056d |
Everything on, phase 4a: one answer everywhere for counts, search, collections and the instance API
Owner decision 2026-10-04: fix the mismatches and every other mismatch of the same kind.
- One counting rule (Domain/Privacy/Counted), Mastodon's. It is used for a persona's statuses_count, its outbox
totalItems, NodeInfo localPosts and the instance status_count, which used to count four different things. It
counts every post that is neither deleted nor a DM, boosts included, and circle and located posts too (owner
decision). A group's count includes its remote members' posts.
- Users. Personas of banned or deleted roots no longer count, and are not found in search. NodeInfo now gives
activeMonth and activeHalfyear, and the v2 instance gives active_month instead of a constant 0.
- replies_count counts only public and unlisted replies, so it no longer tells anyone that a private reply exists.
Migration _012 recounts it.
- A remote account that deletes itself takes everything out of every count (GoneActors): its likes, downvotes,
reactions and poll votes go and their counters come back, as do its boosts', replies' and quotes' counts, and its
notifications. Lookups, account lists, search and favourited_by no longer show it. Migration _012 applies this to
accounts already gone.
- Deleting a post also deletes its pins and the local boosts of it.
- /stalking gives the same total as following_count. Members are still never listed, and hide_collections is now
always true, since the setting never did anything.
- Joining a community by invitation is following it, so /flock and /groupies agree; leaving unfollows.
- Search. Anyone may search, as on Mastodon; resolve and offset need a sign-in, offset pages, and deleted accounts
are never found.
- notifications/unread_count counts what the list shows, and the owner's follower and following lists page with
Link.
- The instance API advertises what is enforced:
- max_characters, now enforced with a 422;
- max_pinned_statuses = MaxPins;
- the media types and limits MediaService and MediaOptions accept;
- PollService's limits;
- the configured languages;
- no streaming URL until streaming exists.
domain_count counts the servers we have exchanged with; which ones stays unpublished (peers is empty).
- Routes Mastodon answers now answer instead of 404:
- directory, tags/{name}, timelines/link and identity_proofs;
- instance/languages, translation_languages, domain_blocks and privacy_policy;
- the v1 and v2 notification policy, and notification requests.
Also, from phase 3: a recovered password ends /clientapi sessions through a per-root SessionStamp claim instead of
comparing the JWT's whole-second nbf with the change time. That comparison let a token issued in the same second
survive, which made a test flaky.
671 tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
|
||
|
|
8c2eba6cbb |
Everything on, phase 3: sign-in and recovery tell nothing, recovered passwords end sessions, deleted roots are gone everywhere
Owner decision 2026-10-04: fix the account privacy findings.
- Sign-in. Every failure answers "That username and password do not match." after the same work: an unknown login
is hashed against a decoy, and the comparison is constant-time. "Banned" is told only to someone who gave the right
password. This covers /clientapi/user/login, /invitation/login and /oauth/login.
- Recovery.
- Every request answers the same sentence and queues a SendRecovery job, whether or not the account exists or has an
email. The lookup, the code and SMTP move to RecoveryJob, so neither the answer nor its timing says anything.
- Codes are kept only as a SHA-256 hash, for one hour. Migration _011 drops the plaintext ones, which never expired.
- A recovered password ends every session of the root. RootSessions sets CredentialsChangedAt, which JwtEvents
checks against the JWT's issue time, now stamped as nbf, and revokes each persona's OAuth tokens and authorizations.
- Deleting a root (RootRemoval: the admin route, or the restored self-delete at /clientapi/user/delete, which asks for
the password).
- Its sessions end.
- Each persona and each group it owns sends Delete{Actor} to its followers, its members and the accounts it follows.
- The personas' posts are emptied.
- /peasants/{name} answers 410 with a Tombstone (formerType Person or Group), as do its inbox and WebFinger, through
LocalActorService.Gone. The names stay reserved.
- The root keeps only a unique `deleted-{id}` name; the second deletion on an instance used to collide on
"Deleted user".
Also, from phase 2's pasture: GoToSocial files a circle post like a DM and shows it only to accounts it mentions. Each
member's copy, and a member's refetch, now also mentions that member silently. The GoToSocial scenario checks circle
posts in conversations, like DMs, and they pass there now, as on Mastodon.
657 tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
|
||
|
|
c5e4934ba6 |
T5: OAuth and the client API over HTTP
96 integration tests through PrivaPubHost, the real pipeline end to end:
- OAuth: the token's subject is the persona, and neither the token response,
verify_credentials nor the stored token entries name the root. A wrong password shows
an error and sets no login cookie; a login without the antiforgery token is a 400; the
return address never leaves the site; deny answers access_denied with no code; another
root's persona re-renders the choice with no code; a banned root is sent back to the
login and a code issued before the ban buys no token; force_login asks again; a code
works once and its reuse revokes the token it bought; password and refresh_token
grants are refused; a client_credentials token gets 401 on user routes; a read-only
token gets 403 with a Mastodon error on POST /api/v1/statuses; follow covers
read:follows; revoke works; the login and authorize pages send their CSP and no-store;
the 11th /oauth/login from one address in a minute is a 429.
- Accounts: sign-up, duplicates in any case, invalid models answer 400 with a message,
login and logout, recovery email, settings, password change, invitation sign-up and
login (refusing a persona named after the login), recovery without an email, through
an unreachable mail server, with a wrong and with a valid code, token refresh, the 11th
sign-up from one address, expired, garbage and foreign-key JWTs.
- Personas: a rootId in the body is ignored, the username regex and reserved names hold,
personas and groups share ReservedName, an update delivers Update{Person} to followers,
PublishedOn and the id's day fall within two weeks before creation, the list holds only
one's own personas, another root's persona cannot be updated.
- Groups: communities and circles are created, joining takes the code and the password,
members leave and owners cannot, a remote follow request becomes a member only on
approval, a circle never shows in lookup, account by id, v2 search or /@name, and its
/flock and /wardens answer 404 unsigned and to non-members, 200 to a member's signed GET.
- Moderation (Exclusive, it suspends localhost): ban, unban and remove; non-admins get
403; reports are listed without the reporter and resolved; domain blocks are inserted,
listed and deleted, bad domains refused, and a suspended server's delivery is answered
202 and kept nowhere; the data endpoints.
- AdminCommands: exit codes 0, 1 and 2 and the resulting policies.
Fixed:
- A banned or removed root kept using /clientapi with its JWT until it expired: only /api
re-checked the root. JwtEvents.TokenValidated now loads the root and fails the request
when it is banned or deleted, and takes the policy claims from the database, so a
demoted admin loses admin at once (and a promoted one gains it).
- The 401 and 403 bodies JwtEvents writes were PascalCase while every other /clientapi
answer is camelCase; they now use the web defaults.
- /clientapi/user/sniff/again (token refresh) answered an empty 200; it now answers a
fresh JwtUser, like login.
- Password recovery answered SMTP reply codes as HTTP statuses (421, 454, 554, and 550 for
an invalid address); a mail server failure is now 503 and an invalid address 400.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
|
||
|
|
125a49a1a0 |
T4: nothing answers 500
NeverFiveHundredTests walks every endpoint the server maps and fills each route parameter
with junk ('x', zeroed and random ObjectIds, a dot-dot segment, 5000 characters). It calls
each one anonymously, as a persona, and with a junk token, and asserts that nothing
answers 5xx or throws, and that every /api error carries a Mastodon error body.
It found two bugs:
- A junk 'Authorization: Bearer' on any non-/api route, anonymous ones included
(/build.json, /peasants/*, inboxes), broke the response. JwtEvents.AuthenticationFailed
wrote a body during authentication and the endpoint then wrote its own. The 401 body now
comes from the Challenge event, which runs only when an endpoint needs a user.
- /api answered 401, 404 and 429 with no body. UseMastodonErrorBodies gives any /api error
that leaves without a body Mastodon's {"error": ...}.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
|
||
|
|
4d9be37c1c |
Mastodon clients can sign in: OAuth with a persona per token
OpenIddict 7.7 (MongoDB stores, keys kept in Mongo so tokens survive
restarts) serves /oauth/authorize, /oauth/token, /oauth/revoke and the
discovery documents, including /.well-known/oauth-authorization-server:
- authorization code (PKCE optional) and client credentials, Mastodon's
scopes including the granular ones, non-expiring reference tokens,
`created_at` in the token response, and the urn:ietf:wg:oauth:2.0:oob
page that shows the code;
- /oauth/login signs the private login into a fifteen-minute cookie that
only /oauth sees (rate limited, antiforgery-protected); /oauth/authorize
then asks which persona the application acts as. The token's subject
is that persona's id and nothing else; no root id reaches a token, an
authorization or a log line;
- the token exchange refuses a persona whose login is banned or deleted,
and every API request checks the same.
/api/v1/apps registers applications dynamically, /api/v1/apps/
verify_credentials, /api/v1/instance (v1 and v2, "4.2.0 (compatible;
PrivaPub)") and verify_credentials answer in Mastodon's shapes: snake_case
with explicit nulls, Rails-style parameters from query, form or JSON,
{"error": ...} on failure, Link paging. CORS exposes Link.
/api goes to OpenIddict validation and everything else to the existing
JWT; the JWT failure handler no longer sends the exception and stack trace
to the client.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
|
||
|
|
1e66851113 | Saving |