Skip to content

feat: list a profile's followers and following in the API - #2

Closed
simonhamp wants to merge 15 commits into
feat/mobile-apifrom
feat/mobile-api-follow-lists
Closed

simonhamp wants to merge 15 commits into
feat/mobile-apifrom
feat/mobile-api-follow-lists

Conversation

@simonhamp

@simonhamp simonhamp commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Adds GET /api/v1/users/{username}/followers and /following for the mobile app's follower lists.

They list people the way the web's connections pages do: newest follow first, 30 a page, cursor-paginated. Like the profile and its commits, they're public and sit in the optional-signature group. A signed request also gets each person's lane, is_followed (the signed-in user follows them) and follows_you (they follow the signed-in user). Across a block a signed request gets a 403 with user_blocked or user_unavailable, the same as the profile, where the web answers 404.

What changed:

  • Api\UserFollowersController@index and Api\UserFollowingController@index, each with its own ListApiUser…Request for the block check.
  • UserConnectionsQuery holds the order and the two withExists for a viewer, shared by both controllers.
  • UserResource gains follows_you through whenHas, next to the existing is_followed. I reused is_followed rather than adding is_following, since that's the name the resource, the web's connections page and the API's notifications already use for "you follow them".
  • README lists both endpoints and the two signed fields.

The web has no way to remove one of your own followers, so there's no endpoint for that.

Worth checking: the cursor pagination orders by follows.created_at then users.id, the same as the web. The tests page through 31 people with distinct follow times and check there's no query per row.

Tests: new feature tests for both controllers and a unit test for the query, all new files at 100% coverage. On this base composer test passes: lint, types and type coverage as is, and all 2,372 tests at 100% coverage with APP_URL=http://localhost:8000, which the CommitScriptTest tests need.

I checked it end to end from the app on a simulator against a local copy of this branch, on my own lists and on a test user with 36 followers. Screenshots are on the app PR.

App side: NativePHP/dev-null-app#10

The mobile API signs requests the same way the commit script signs commits, so it needs the same checks: a valid signature, a recent timestamp, a key on a user's GitHub account and a nonce used once. SignedCommits did all of that inline.

A new SshSignatures service holds those checks now. verify() checks the signature and returns the key. authenticate() checks the timestamp and nonce, finds the key's owner and spends the nonce once it finds them. SignedCommits calls both, in the same order as before and with the same messages, so the script sees no difference.

The nonce cache key is now ssh-signature-nonce instead of commit-nonce, since API requests will use it too.
The mobile app keeps an ed25519 key on the device, and the user adds it to their GitHub account, usually as a signing key. The app signs each request to /api/v1 with that key, the way the commit script signs commits. There are no passwords or tokens.

The app sends X-Devnull-Timestamp, X-Devnull-Nonce and X-SSH-Signature. The signature covers the timestamp, nonce, method, path with query and raw body, joined by newlines, in the devnull-api namespace. SignedRequests rebuilds that message and runs the shared checks. AuthenticateSignedRequest turns a failure into a 401.

The public endpoints answer requests without a signature exactly as before. A signed request to the same URLs gets what the web shows that user: their own pending commits, whether they follow the profile, its activity, streaks and pinned commit, and a commit's ancestors. The resources put these on top of the public fields, so every name the public API uses stays the same. A request with any of the signature headers has to be signed right, so a broken signature is a 401 and never the public view.

The profile and commit page queries move into UserCommitsQuery, PinnedCommitQuery and CommitPageQuery, so the web and the API load them the same way. They load what upstream's controllers load now: the card data from CommitBuilder::withCardDataFor(), and the reply previews on a profile.

Visibility is the web's, blocks included. A commit across a block is a 404, like on the web, and so are its ancestors. The web shows neither of two people across a block the other's profile, so a signed request for one is a 403, with user_blocked for the one who blocked and user_unavailable for the other. ShowApiUserRequest and ListApiUserCommitsRequest make that check.

A request without a signature always gets the public view, even when a user is signed in some other way. API routes have no session, so that only happens in tests, like upstream's own tests that sign in on the web and then read the public API.

Signed requests get 120 requests a minute per user instead of 60 per IP. GET /api/v1/me is the first endpoint only signed requests can use. If GitHub can't be reached to check a key, the API answers 503 with the message the script prints.

Errors from the signed API have a code next to the message, so the app can tell them apart without reading the message. A bad, expired, reused or malformed signature is invalid_signature and a key that isn't on anyone's GitHub account is key_not_on_github, both 401s. GitHub being down is github_unreachable, and going over the 120 a minute is rate_limited. ApiException carries the code and renders the JSON, and ApiErrorCode lists the codes with their statuses. Unsigned requests to the public endpoints get the same 404s and 429s as before, without a code.
These endpoints are only for signed requests:

- GET /api/v1/log/{log} is one of the web's logs, 20 commits a page.
- GET /api/v1/commits/{commit}/replies is the replies under a commit, 50 a page, with quotes of it among them and deleted replies that still have replies as tombstones, as on the web. It works for a deleted commit too, as its replies stay in the thread.
- GET /api/v1/notifications is grouped the way the web groups them, with the first few actors, how many there are and how many are unread. Like the web, listing them marks every notification as read, and read_at says when, so the ones read since then are the new ones.

They reuse the web's queries, so the visibility rules are the same, mutes and blocks included. A notification's commit comes with its author and the commit it replies to, when the user may see that one.
Signed requests can now write:

- POST /api/v1/commits makes a commit, or a reply with parent_id. It answers 201 with the commit as its page shows it. It has the same validation, moderation and limit of 10 commits a minute as the web: the body rules are the web's own, a commit across a block can't be replied to, and a pending commit goes to the same AI review. When the limit is hit, ThrottleCommits answers in JSON for the API, with the rate_limited code.
- DELETE /api/v1/commits/{commit} deletes one of the user's commits.
- POST and DELETE /api/v1/users/{username}/follow follow and unfollow. Following yourself, or anyone across a block, is a 403, like on the web. Both share the web's limit of 30 a minute. Going over it, or any other limit the web puts on social actions, answers the app with rate_limited.

The signature tests now include two messages signed with ssh-keygen itself, so the documented format is checked against the real tool.
PRODUCT.md listed a write API among the things devnull leaves out, which this branch adds. It now describes the signed API next to the public one, and the exclusion is down to API tokens, which there still aren't any of.

The README gets a short section after Public API with the headers, the signed message and the error codes, including the ones for a profile across a block.
The web's profile now shows a "follows you" badge (nunomaduro#149) from its followsYou prop. A signed GET /api/v1/users/{username} sends the same thing as follows_you, next to is_following, so the mobile app can show the badge too. Unsigned requests don't get it, like is_following.
A device is an SSH key that its owner approved for the /dev/null app on the web. It has the key's fingerprint, a name like "devnull on iPhone", when it was approved and when it was last used. A fingerprint is one device, so approving a key again moves it to whoever approved it last.

It lives apart from ssh_keys on purpose. UpdateUserSshKeys rewrites ssh_keys from GitHub on every sync, while an approval should stay until the user revokes it. Devices go when their user is deleted.

Nothing reads it yet.
A new settings page at /settings/devices, next to Safety, lists the user's approved devices with their name, fingerprint, when they were approved and when they were last used, newest first.

Revoke deletes the device after an inline confirm that stays in the row's slot, then the row collapses like a deleted commit, and focus moves to the next row. Someone else's device is a 404.

It follows the usual shape: DeviceController with index and destroy, DeleteDeviceRequest and the DeleteDevice action.
The app signs in the way an OAuth app does, with devnull as the provider. It makes a key and a PKCE verifier, then opens /mobile/authorize in a browser session with the key's fingerprint, a device name, the S256 challenge, a state and a devnull:// redirect_uri. A guest signs in with GitHub first and comes back to the page. It asks "Approve devnull on iPhone?" and shows the fingerprint and the account it signs in as.

Approving is a plain form post, so the browser follows the 303 back into the app with a one-time code and the state. Cancel is a plain link to the redirect with error=access_denied. The code is random, kept only as a hash, good for 5 minutes and once, and bound to the user, the key, the device name, the challenge and the redirect. Each user can approve 10 times a minute. A redirect_uri that doesn't start with one of the prefixes in config/mobile.php, or any other bad parameter, gets a plain error page and never a redirect.

The app redeems the code with POST /api/v1/devices and {"code", "code_verifier"}, signed with the key. Only the signature, its timestamp and its nonce are checked, as the key isn't approved yet. The code has to be unexpired and unused, handed out for that key, and match base64url(sha256(code_verifier)). Anything else is a 400 with invalid_grant. CreateDevice redeems it in a transaction with the code locked, so two requests with one code can't both get a device. The answer is the user, like GET /api/v1/me. Each key gets 12 tries a minute and each address 60, counted before signatures are checked.

From then on a signed request is the user who approved its key. The key no longer has to be on their GitHub account, and the API never asks GitHub about it. A key nobody approved, or one that was revoked, is a 401 with device_not_approved, so a key made for the GitHub Action still can't use the API, as the Terminal page promises. key_not_on_github and github_unreachable go, since the API doesn't check GitHub any more. The commit script and the Action go through SignedCommits and keep checking GitHub as before. SshSignatures gains checkFreshness() for the API's half.
The README's signed requests section now says a key signs in once its user approved it as a device, and that it doesn't need to be on their GitHub account. A new "Signing in" part walks through the flow with example URLs: the authorize page and its parameters, the approve and cancel redirects, and POST /api/v1/devices with each status and code it answers with. The error codes table gains invalid_grant and device_not_approved, and loses key_not_on_github and github_unreachable.

PRODUCT.md's signed API bullet says the same in a few sentences, and Settings now lists Devices.

The Terminal page says a CI key "can sign devnull commits, but never push code or log in". That holds: a key on GitHub alone can't use the API, as only a device approved on the web can, so it stays as it is.
Upstream replaced the Everyone log with Latest and Trending. GET /api/v1/log/{log} now takes following, latest and trending, the same values as the web, and everyone is a 404. Each log uses the web's own query, so the app gets the same ranking, the same 20 a page, and the same mutes and blocks.

Trending isn't newest first, so the response says is_ranked and which commit was newest when it was loaded. With ?since= the app gets what the web shows above a ranked log: the newer commits other than the user's own as latest_commits, and new_commits_count, which every log returns. ?since= goes through the web's ShowLogRequest. An empty Following log comes with suggestions, like the web.

The API doesn't change the log the web remembers for the user. The app keeps its own tabs.
Upstream regrouped the notification inbox and added filters. GET /api/v1/notifications now goes through the web's ListNotificationsRequest, so it takes the same ?type= values (all, replies, quotes, stars, mentions, follows, moderation, with all as the default) and the same ?through= the web uses to refresh every group it has loaded. The response echoes the filter as filters.type, like the web's prop.

Each notification now says which group it leads (group_id) and how many events the group has (event_count), and the actor says whether the user follows them (is_followed), so the app can offer a follow back. These are the web's names.

GET /api/v1/notifications/{notification}/group lists the notifications of one group, filtered the same way, through the web's ShowNotificationGroupRequest and query. Like the web, it marks nothing as read.

The web shows no unread counts per filter, so the API doesn't either.
The web's commit cards now show an excerpt of a long commit, a poll, the commit a quote quotes, and under each commit in a list its newest reply or quote. Signed responses carry the same, with the web's names, so the app can draw them:

- body_segments and preview_segments come from the web's CommitBodies, so a long commit has the excerpt a list shows, or null when it fits.
- poll is the web's own poll array: closes_at, is_closed, has_voted, total_votes, and options with votes_count and voted. PollResource adds each option's percentage, rounded like the web rounds it. Counts and percentages stay null until the user voted, wrote the poll, or it closed, as on the web.
- quoted_commit_id says the commit quotes another, and quoted_commit embeds it with its author and poll. It is a tombstone once deleted, and null when the user may no longer see it, such as across a block.
- reply_preview is the newest visible reply or quote under a commit in the log, a profile and the pinned commit, with the web's next_cursor. CommitBuilder hands these over as arrays, so ReplyPreviewsQuery loads them again as commits in one query per page, and they come in the same shape as every other commit.

The link card needs nothing new. link_preview is already the card the author chose, and upstream gave it the web's embed for every request. Unsigned requests get none of the fields above.
The web's profile now says whether you mute the person and shows their achievements and Unix rank. A signed GET /api/v1/users/{username} adds is_muting and achievements, the web's names, from the web's own UserAchievementsQuery, sharing the streaks it already computes the way the web does.

Muting doesn't hide a profile or its commits, on the web or here. It only quiets the logs, replies and notifications.
GET /api/v1/users/{username}/followers and /following list people the
way the web's connections pages do: newest follow first, 30 a page.
A signed request also gets each person's lane, whether its user follows
them (is_followed) and whether they follow its user (follows_you).
Across a block a signed request gets user_blocked or user_unavailable,
as the profile does.
@simonhamp

Copy link
Copy Markdown
Owner Author

Superseded by nunomaduro#556, rebased onto production now that nunomaduro#130 is merged.

@simonhamp simonhamp closed this Oct 19, 2026
@simonhamp
simonhamp deleted the feat/mobile-api-follow-lists branch October 19, 2026 23:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant