Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Identity is GitHub. Every account is a real GitHub user, and the profile (avatar
- Sign-in is GitHub OAuth only. Profile changes happen on GitHub ("Edit on GitHub"), then sync at next login or with "Sync from GitHub" (once a minute).
- Everything except the landing page, the public read API, and profile feeds requires login.
- Public read API: read-only JSON under `/api/v1`, no authentication, for public profiles (with counts), a user's commits or replies, and single commits. It returns exactly what a guest's link preview may show: approved, non-deleted commits only (with their images), approved reply counts, and a parent only while it is approved. Never emails or other hidden profile fields. 60 requests a minute per IP.
- Signed API: the mobile app uses the same `/api/v1`, signing every request with an SSH key it keeps on the device, which the user approved as a device (no passwords or API tokens). The app signs in like an OAuth app with PKCE: it opens `/mobile/authorize` in a browser, the user signs in with GitHub if they need to and approves it, and the app redeems the one-time code for the device with a request signed by that key. The approval is the proof, so the key doesn't need to be on GitHub, and a key that is on GitHub, like one made for the GitHub Action, can't use the API without it. Devices are listed, with when each was last used, and revoked under Settings → Devices. A signed request to a public endpoint gets what the web shows that user, such as their own pending commits. Signed-only endpoints read the log, replies, notifications, and the people the user mutes and blocks, and commit, reply, delete, follow, mute, and block, with the web's validation, moderation, and rate limits. A bad or expired signature is a 401, never the public view. 120 requests a minute per user.
- Profile feeds: every profile has an Atom feed of its latest 50 approved top-level commits at `/@{username}/feed.atom`, their images as enclosures, advertised with `<link rel="alternate">` on the profile's guest preview.
- First sign-in leads to a short setup at `/onboarding`: confirm the mirrored profile, pick a color, follow a few suggested developers, learn the composer, `?` shortcuts and terminal commits, then land on the log (or the page the user was headed to). Skippable at any step (Skip setup or Esc); finishing or skipping ends it for good. Accounts that existed before it never see it.
- Profile links (`/@{username}`) and links to approved commits (`/@{username}/commits/{id}`) unfurl with a link preview (the author and their bio or counts, or the commit body and reply count) for guests and crawlers; a guest opening one sees the landing page, and the profile or commit itself stays behind login. Pending and rejected commits are a 404 for guests.
Expand All @@ -31,7 +32,7 @@ Identity is GitHub. Every account is a real GitHub user, and the profile (avatar
- Profiles live at `/@{username}` with commit, following, and follower counts, a commit activity graph with longest and current streaks, and Commits and Replies tabs. Commits is the default and lists the user's top-level commits; `?tab=replies` lists their authored replies with visible parent context. Both feeds use infinite scroll.
- Operations (`/operations`, admins only; a 404 for everyone else): database and cache connectivity, the GitHub API rate limit when GitHub credentials are configured, the database queue backlog per queue with the age of its oldest job, the most recent failed jobs (each can be retried or forgotten), pending moderation and link previews, link previews that came back without metadata in the last 24 hours, and signups, commits, and replies over the last 24 hours and 7 days. It refreshes every 15 seconds.
- Settings: read-only profile, color (pick one of the six graph lanes or keep the automatic one; the only profile setting /dev/null owns), appearance (light/dark/system), delete account.
- Settings: read-only profile, color (pick one of the six graph lanes or keep the automatic one; the only profile setting /dev/null owns), the Markdown snippet and live preview to embed the activity graph SVG, appearance (light/dark/system), delete account.
- Settings: read-only profile, color (pick one of the six graph lanes or keep the automatic one; the only profile setting /dev/null owns), the Markdown snippet and live preview to embed the activity graph SVG, appearance (light/dark/system), devices (the mobile app on each device the user approved, when it was last used, and revoking it), delete account.

## Capabilities and Constraints

Expand All @@ -51,7 +52,7 @@ Identity is GitHub. Every account is a real GitHub user, and the profile (avatar
- People search: find developers by name or GitHub username (⌘K). Commits are not searchable.
- Rate limits, per user: stars and saves 60/min each, pins 20/min, follows 30/min, people lookups (search, mention suggestions, hover cards) 120/min combined, exports 3/hour. Throttled actions keep the page and show a toast; throttled pages show the 429 page.
- Public read API and Atom feeds: read-only, so nothing can be created, starred, or followed through them, and there are no API tokens. Lists are cursor-paginated, 20 per page.
- Deliberately excluded for now: likes/reactions, reposts without a comment of your own (quotes are in), images/media, hashtags, searching commits, DMs, other OAuth providers, a write API or API tokens, moderation tools, blocking/muting, header/banner images. Future work must not design for these as if they exist.
- Deliberately excluded for now: likes/reactions, reposts without a comment of your own (quotes are in), images/media, hashtags, searching commits, DMs, other OAuth providers, API tokens, moderation tools, blocking/muting, header/banner images. Future work must not design for these as if they exist.
- GitHub username renames: signing in takes the current GitHub username. If a stale account still holds it because its owner renamed on GitHub, that account moves to a `_renamed-<github id>` placeholder until its owner signs in again. Accounts are never merged.

## Brand Commitments
Expand Down
89 changes: 89 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,95 @@ curl -s https://dev-null.sh/api/v1/users/nunomaduro/commits | jq '.data[] | {sho

Every profile also has an Atom feed of its latest 50 top-level commits at `/@{username}/feed.atom`, with their images as enclosures, cached for five minutes.

## Signed requests

The mobile app signs its requests to `/api/v1` with an SSH key it keeps on the device, which the user approved on the web (see [Signing in](#signing-in)). A signed request to the endpoints above gets what the web shows that user. The log, replies, notifications, committing, deleting, following, muting and blocking only take signed requests. Signed requests are limited to 120 a minute per user.

Commits in a signed response carry what the web's cards show: `preview_segments`, the excerpt a list shows of a long commit (or `null`), the `poll` with the user's view of its votes and each option's `percentage`, the `quoted_commit` (a tombstone once deleted, or `null` when the user may not see it, while `quoted_commit_id` still says a commit was quoted), and in lists the `reply_preview` the web shows under each commit.

A signed profile also says `is_muting` and has the web's `achievements`, with the Unix rank.

`POST` and `DELETE /api/v1/users/{username}/mute` mute and unmute someone, and `POST` and `DELETE /api/v1/users/{username}/block` block and unblock them, as the web does: nobody can mute or block themselves, muting is silent, and blocking ends the follows both ways, which unblocking doesn't bring back. Each answers with the user and the new `is_muting` or `is_blocking`. `GET /api/v1/me/muted` and `GET /api/v1/me/blocked` list the people the user mutes and blocks, last first, as Settings → Safety does, 20 a page.

`GET /api/v1/log/{log}` serves the web's three logs, `following`, `latest` and `trending`, with the same queries and 20 commits a page. Trending is ranked, so it also says `is_ranked` and the `newest_commit_id` when it was loaded. With `?since={id}`, every log counts the `new_commits_count` newer than that commit, and Trending also returns them as `latest_commits`, the way the web offers them above it. An empty Following log comes with a few `suggestions`.

`GET /api/v1/notifications` groups notifications like the web and takes the web's `?type=` filter: `all` (the default), `replies`, `quotes`, `stars`, `mentions`, `follows` or `moderation`. The response echoes it as `filters.type`. Listing them marks every notification as read, whatever the filter, and `read_at` says when, so the groups read at or after it are the new ones. `GET /api/v1/notifications/{id}/group` lists the notifications of one group, with the same filter, the way the web expands it.

Each request sends three headers:

| Header | Value |
|---|---|
| `X-Devnull-Timestamp` | Unix time in seconds, within 5 minutes of the server's clock |
| `X-Devnull-Nonce` | 32 random hex characters, used once |
| `X-SSH-Signature` | The SSHSIG signature, base64 on one line |

The signature is made with `ssh-keygen -Y sign -n devnull-api` over these lines, joined by `\n`: the timestamp, the nonce, the method, the path with its query string exactly as sent, and the raw body.

```text
1760534400
3f9c2a7e5b1d4c8f9a0b2c3d4e5f6a7b
POST
/api/v1/commits
{"body":"Hello from my phone"}
```

A request with any of these headers has to be signed right. A bad or expired signature is a `401`, never the public view. A request without them always gets the public view, and across a block a signed request gets neither profile: it is a `403`, `user_blocked` for the one who blocked and `user_unavailable` for the other, and their commits are hidden as on the web.

A key signs in once its user approved it as a device. It doesn't have to be on their GitHub account, and being on it isn't enough, so a key made for the GitHub Action can't use the API. Users see their devices, when each was last used, and revoke them under Settings → Devices (`/settings/devices`). A key that isn't approved, or was revoked, gets a `401` with `device_not_approved`, and the app signs in again.

### Errors

An error is JSON with a `message` to show the user. The errors the app has to tell apart also have a `code`, which stays the same when the message changes:

```json
{"message": "This key isn't approved for the /dev/null app. Sign in again in the app.", "code": "device_not_approved"}
```

| Status | `code` | When |
|---|---|---|
| `400` | `invalid_grant` | `POST /api/v1/devices` only: the code is wrong, expired or used, was handed out for another key, or the verifier doesn't match. |
| `401` | `invalid_signature` | The signature is missing, malformed or wrong, has expired, or was used already. |
| `401` | `device_not_approved` | Nobody approved the key as a device, or it was revoked. |
| `403` | `user_blocked` | The user blocked the profile they asked for. Neither of them sees the other's commits. The error comes with the `user` they blocked, as the web shows them. |
| `403` | `user_unavailable` | The profile isn't available to the user, because it blocked them. |
| `429` | `rate_limited` | Over 120 signed requests a minute for the user, 10 commits a minute, or 30 follows and unfollows a minute. `Retry-After` says when to try again. |

Other errors have no `code`, like a `422` for a commit that fails validation or a `404` for one that doesn't exist. Unsigned requests to the public endpoints get the same errors as before, without codes.

### Signing in

The app signs in the way an OAuth app does, with the authorization code flow and PKCE ([RFC 7636](https://www.rfc-editor.org/rfc/rfc7636)), and /dev/null as the provider. The code it gets is bound to its key, so only the app that holds the key can redeem it.

1. The app makes an ed25519 key and a random `code_verifier`, and opens this page in a browser session (`ASWebAuthenticationSession` on iOS):

```text
https://dev-null.sh/mobile/authorize
?fingerprint=SHA256:hPEdU4FVZ1lfjzbPyBhqTWG2qxUu3ZbCnD8mZw9t2Vk
&device_name=devnull%20on%20iPhone
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&state=af0ifjsldkj
&redirect_uri=devnull://auth/callback
```

`code_challenge` is `base64url(sha256(code_verifier))` without padding. `redirect_uri` has to start with `devnull://`. A guest signs in with GitHub first and comes back to the page. A request with a missing or bad parameter gets an error page and is never redirected.
2. The page asks "Approve devnull on iPhone?", with the key's fingerprint and the account it signs in as. **Approve** redirects (`303`) to `devnull://auth/callback?code=…&state=af0ifjsldkj`. **Cancel** goes to `devnull://auth/callback?error=access_denied&state=af0ifjsldkj`. The app checks that `state` is the one it sent. A code is good for 5 minutes and only once, and each user can approve 10 times a minute.
3. The app redeems the code with `POST /api/v1/devices`, signed with the key like any other request, though the key isn't approved yet:

```json
{"code": "…", "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"}
```

The key becomes an approved device of the user who approved it, and the answer is `200` with the user, like `GET /api/v1/me`. From then on every request it signs is theirs, until they revoke the device.

| Status | `code` | Means |
|---|---|---|
| `200` | | The device is approved. The body is the user, with `unread_notifications_count`. |
| `400` | `invalid_grant` | The code is wrong, expired or used, was handed out for another key, or the verifier doesn't match. Sign in again. |
| `401` | `invalid_signature` | The signature is bad, expired or used already. |
| `422` | | `code` or `code_verifier` is missing, or the verifier isn't 43 to 128 characters of `A-Z a-z 0-9 - . _ ~`. |
| `429` | `rate_limited` | Over 12 tries a minute for one key, or 60 from one IP, counted before signatures are checked. `Retry-After` says when to try again. |

## Available Tooling

### Development
Expand Down
32 changes: 32 additions & 0 deletions app/Actions/CreateAuthorizationCode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Dto\MobileAuthorization;
use App\Models\AuthorizationCode;
use App\Models\User;

final readonly class CreateAuthorizationCode
{
/**
* Hands out a one-time code for the devnull app to redeem for a device, bound to what the user approved. Only its
* hash is kept, and it expires after a few minutes.
*/
public function handle(User $user, MobileAuthorization $authorization): string
{
$code = bin2hex(random_bytes(32));

$user->authorizationCodes()->create([
'code_hash' => AuthorizationCode::hash($code),
'fingerprint' => $authorization->fingerprint,
'device_name' => $authorization->deviceName,
'code_challenge' => $authorization->codeChallenge,
'redirect_uri' => $authorization->redirectUri,
'expires_at' => now()->addMinutes(AuthorizationCode::EXPIRES_AFTER_MINUTES),
]);

return $code;
}
}
43 changes: 43 additions & 0 deletions app/Actions/CreateDevice.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\AuthorizationCode;
use App\Models\Device;
use Illuminate\Support\Facades\DB;

final readonly class CreateDevice
{
/**
* Redeems an authorization code: the key it was handed out for becomes an approved device of the user who approved
* it, and the code is used up. A key that was approved before, even for someone else, is approved again for them.
*
* The code is locked while it is redeemed, so of two requests with the same code only one gets a device. The other
* gets null, as does a code that expired since it was checked.
*/
public function handle(AuthorizationCode $authorizationCode): ?Device
{
return DB::transaction(function () use ($authorizationCode): ?Device {
$code = AuthorizationCode::query()
->whereKey($authorizationCode->id)
->where('expires_at', '>', now())
->lockForUpdate()
->first();

if (! $code instanceof AuthorizationCode) {
return null;
}

$code->delete();

return Device::query()->updateOrCreate(['fingerprint' => $code->fingerprint], [
'user_id' => $code->user_id,
'name' => $code->device_name,
'approved_at' => now(),
'last_used_at' => null,
]);
});
}
}
18 changes: 18 additions & 0 deletions app/Actions/DeleteDevice.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\Device;

final readonly class DeleteDevice
{
/**
* Revokes the device: the app on it can't sign in until it is approved again.
*/
public function handle(Device $device): void
{
$device->delete();
}
}
22 changes: 22 additions & 0 deletions app/Actions/UpdateDeviceLastUsedAt.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\Device;

final readonly class UpdateDeviceLastUsedAt
{
/**
* Writes at most once a minute per device, rather than on every request the app makes.
*/
public function handle(Device $device): void
{
if ($device->last_used_at?->isAfter(now()->subMinute()) === true) {
return;
}

$device->update(['last_used_at' => now()]);
}
}
49 changes: 49 additions & 0 deletions app/Dto/MobileAuthorization.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace App\Dto;

/**
* What the devnull app asks for when it sends the user to approve it on the web: which key and device to approve,
* the app's PKCE challenge, and where to send the answer, with the state the app gave.
*/
final readonly class MobileAuthorization
{
public function __construct(
public string $fingerprint,
public string $deviceName,
public string $codeChallenge,
public string $state,
public string $redirectUri,
) {
//
}

/**
* The app's redirect_uri with the given parameters and the state added to its query.
*
* @param array<string, string> $parameters
*/
public function redirectTo(array $parameters): string
{
return $this->redirectUri.(str_contains($this->redirectUri, '?') ? '&' : '?').http_build_query([...$parameters, 'state' => $this->state]);
}

/**
* The parameters as the app sent them, for the approval form to send back.
*
* @return array{fingerprint: string, device_name: string, code_challenge: string, code_challenge_method: string, state: string, redirect_uri: string}
*/
public function toParameters(): array
{
return [
'fingerprint' => $this->fingerprint,
'device_name' => $this->deviceName,
'code_challenge' => $this->codeChallenge,
'code_challenge_method' => 'S256',
'state' => $this->state,
'redirect_uri' => $this->redirectUri,
];
}
}
29 changes: 29 additions & 0 deletions app/Enums/ApiErrorCode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace App\Enums;

/**
* The code that comes with an error from the signed API, next to its message, so the mobile app can tell errors apart
* without reading the message. Once released, a code never changes.
*/
enum ApiErrorCode: string
{
case InvalidSignature = 'invalid_signature';
case DeviceNotApproved = 'device_not_approved';
case InvalidGrant = 'invalid_grant';
case UserBlocked = 'user_blocked';
case UserUnavailable = 'user_unavailable';
case RateLimited = 'rate_limited';

public function status(): int
{
return match ($this) {
self::InvalidGrant => 400,
self::InvalidSignature, self::DeviceNotApproved => 401,
self::UserBlocked, self::UserUnavailable => 403,
self::RateLimited => 429,
};
}
}
Loading