v4: users sign in to one gerbeur instance with their account on another — topic instances, each with its own admins, domain and data, had no way to share accounts. Sign-in is federated over a subset of OpenID Connect, the authorization code flow with PKCE: each instance is an identity provider for its own users and a client for the others', and signs what it tells them with an Ed25519 key kept in federation_keys and published at /.well-known/jwks.json, beside its metadata at /.well-known/openid-configuration. Only sign-in is federated: an account from another instance is a local users row linked to (issuer, subject) in user_identities, with no password (password_hash ''), so sessions, roles, permissions, the socket and every foreign key work as before.
A remote user is name@host here, in users.username, in paths (/@alice@music.example) and in mentions, through one handle rule in shared/validation.ts that paths, MENTION_RE and Markdown all build on; Markdown undoes GFM's email autolink of a handle. Their name, email and avatar come from their home at each sign-in and by its signed events (federation_outbox, delivered by the federation job with backoff): a rename at home leaves the old handle an alias here, and a password change or a withdrawn grant ends their sessions here. Their password, email, avatar and name can't change here (assertLocalUser), nor can they sign in elsewhere from here. An admin keeps the trusted and blocked instances on /manage (federation:manage): a trusted instance's users sign in from a button on the login page, a blocked one's can't and are signed out at once, and any other's join with an invite from the register page. The /authorize page asks the user once per instance, and /settings lists the instances their account signs in to, each disconnectable; /login?next= brings a signed-out visitor back to /authorize. Emails stay unique among local accounts only, and migration 0024 adds the tables, makes that index partial and reserves authorize and federation as category slugs. remote/gerbeur-instance.ts is the only way to other instances: https and public addresses only, no redirects, no certificate fallback, unless GERBEUR_FEDERATION_ALLOW_PRIVATE lets two instances on one machine talk. 298 backend tests and 702 frontend tests pass, and deno check, deno fmt, deno lint, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked end to end with two built instances on one machine: sign-in through the other's login and consent pages, consent skipped once granted and asked again after disconnecting, a rename and a password change at home reaching the other, and blocking; screenshots of the login, consent, profile, settings and manage pages at 1280 and 390px. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
210
API.md
210
API.md
@@ -1,44 +1,47 @@
|
||||
# gerbeur's URLs
|
||||
|
||||
Every page and every API endpoint follows one of two rules. This file
|
||||
summarizes them. Each router's module doc in `api/routes/` lists its own
|
||||
endpoints in full, and a route change updates both. `shared/paths.ts` builds
|
||||
and parses the page paths.
|
||||
Every page and every API endpoint follows one of two rules. This file summarizes
|
||||
them. Each router's module doc in `api/routes/` lists its own endpoints in full,
|
||||
and a route change updates both. `shared/paths.ts` builds and parses the page
|
||||
paths.
|
||||
|
||||
## The two rules
|
||||
|
||||
**Pages.** The path is a hierarchy. `@name` is a user. Any other word under
|
||||
the root is a category or an app page. Under a user, each segment is a fixed
|
||||
tab name, and under `dumps` or `playlists`, a slug. A view of a page is one
|
||||
more segment. A default view is the bare path, with no redirect.
|
||||
**Pages.** The path is a hierarchy. `@name` is a user, and `@name@host` a user
|
||||
whose account lives on another instance. Any other word under the root is a
|
||||
category or an app page. Under a user, each segment is a fixed tab name, and
|
||||
under `dumps` or `playlists`, a slug. A view of a page is one more segment. A
|
||||
default view is the bare path, with no redirect.
|
||||
|
||||
**API.** A resource is `/api/<plural>/<key>[/<noun>]`. Its key is its id,
|
||||
except a user's, which may also be `@name`. The `@` keeps the two apart, so no
|
||||
regex sniffing is needed. A read by page path mirrors the page:
|
||||
**API.** A resource is `/api/<plural>/<key>[/<noun>]`. Its key is its id, except
|
||||
a user's, which may also be `@name`. The `@` keeps the two apart, so no regex
|
||||
sniffing is needed. A read by page path mirrors the page:
|
||||
`/api/users/@bob/dumps/<slug>`. The caller's own things live under `/api/me`.
|
||||
Media is a sub-resource. A lookup by another key is a query filter. Each
|
||||
concept has one word, the same as in pages.
|
||||
Media is a sub-resource. A lookup by another key is a query filter. Each concept
|
||||
has one word, the same as in pages.
|
||||
|
||||
## Pages
|
||||
|
||||
| Path | Views | Access | Page |
|
||||
|---|---|---|---|
|
||||
| `/` | `/hot`, `/new`, `/journal`, `/follow`; the bare `/` is the viewer's default tab | anyone, `/follow` signed in | `Index` |
|
||||
| `/<category>` | `/<category>/hot`, `/new`, `/journal`, `/follow` | anyone | `Index` |
|
||||
| `/@<name>` | the overview: dumps beside upvoted | anyone | `UserPublicProfile` |
|
||||
| `/@<name>/dumps`, `/upvoted`, `/playlists`, `/invitees` | | anyone | `UserPublicProfile` |
|
||||
| `/@<name>/follow` | `/@<name>/follow/playlists`; the bare tab lists users | anyone | `UserPublicProfile` |
|
||||
| `/@<name>/manage` | | `user:manage`, on others' profiles | `UserPublicProfile` |
|
||||
| `/@<name>/dumps/<slug>` | | anyone; a private dump, its poster | `Dump` |
|
||||
| `/@<name>/dumps/<slug>/edit` | | its poster, `dump:moderate` | `DumpEdit` |
|
||||
| `/@<name>/playlists/<slug>` | | anyone; a private playlist, its owner | `PlaylistDetail` |
|
||||
| `/search?q=` | `/search/users?q=`, `/search/playlists?q=` | anyone | `Search` |
|
||||
| `/notifications` | | signed in | `Notifications` |
|
||||
| `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` |
|
||||
| `/settings` | | signed in | `Settings` |
|
||||
| `/manage` | | `category:manage` | `Manage` |
|
||||
| `/login`, `/register?token=` | | signed out | `UserLogin`, `UserRegister` |
|
||||
| `/reset-password?token=`, `/unsubscribe?token=` | | anyone | `ResetPassword`, `Unsubscribe` |
|
||||
| Path | Views | Access | Page |
|
||||
| ------------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------ |
|
||||
| `/` | `/hot`, `/new`, `/journal`, `/follow`; the bare `/` is the viewer's default tab | anyone, `/follow` signed in | `Index` |
|
||||
| `/<category>` | `/<category>/hot`, `/new`, `/journal`, `/follow` | anyone | `Index` |
|
||||
| `/@<name>` | the overview: dumps beside upvoted | anyone | `UserPublicProfile` |
|
||||
| `/@<name>/dumps`, `/upvoted`, `/playlists`, `/invitees` | | anyone | `UserPublicProfile` |
|
||||
| `/@<name>/follow` | `/@<name>/follow/playlists`; the bare tab lists users | anyone | `UserPublicProfile` |
|
||||
| `/@<name>/manage` | | `user:manage`, on others' profiles | `UserPublicProfile` |
|
||||
| `/@<name>/dumps/<slug>` | | anyone; a private dump, its poster | `Dump` |
|
||||
| `/@<name>/dumps/<slug>/edit` | | its poster, `dump:moderate` | `DumpEdit` |
|
||||
| `/@<name>/playlists/<slug>` | | anyone; a private playlist, its owner | `PlaylistDetail` |
|
||||
| `/search?q=` | `/search/users?q=`, `/search/playlists?q=` | anyone | `Search` |
|
||||
| `/notifications` | | signed in | `Notifications` |
|
||||
| `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` |
|
||||
| `/settings` | | signed in | `Settings` |
|
||||
| `/manage` | | `category:manage`, `federation:manage` | `Manage` |
|
||||
| `/login`, `/register?token=` | `/login?next=<path>` comes back to a page | signed out | `UserLogin`, `UserRegister` |
|
||||
| `/authorize?client_id=…` | | signed in, else via `/login?next=` | `Authorize` |
|
||||
| `/federation/callback?code=&state=` | | anyone | `FederationCallback` |
|
||||
| `/reset-password?token=`, `/unsubscribe?token=` | | anyone | `ResetPassword`, `Unsubscribe` |
|
||||
|
||||
`/?share_url=` opens the create form on a link shared from Android,
|
||||
`/?chat=open` opens the chat over any page, and `#comment-<id>` scrolls a dump's
|
||||
@@ -46,39 +49,44 @@ page to a comment.
|
||||
|
||||
## Names
|
||||
|
||||
- **Slugs** of categories, dumps and playlists are lowercase letters and
|
||||
digits in words joined by single hyphens (`shared/slug.ts`). A category's is
|
||||
at most 50 characters, a dump's or a playlist's at most 60.
|
||||
- **Slugs** of categories, dumps and playlists are lowercase letters and digits
|
||||
in words joined by single hyphens (`shared/slug.ts`). A category's is at most
|
||||
50 characters, a dump's or a playlist's at most 60.
|
||||
- **A dump's or a playlist's slug** is made from its title when it is created,
|
||||
unique among its owner's items of that kind: a clash gets `-2`, `-3`. It
|
||||
changes only when someone edits it: the edit forms propose
|
||||
`slugify(newTitle)` when the title changes. Its owner may change it 3 times
|
||||
a day; moderators (`dump:moderate`, `playlist:moderate`) may edit any slug,
|
||||
at any time.
|
||||
- **A username** is 1 to 32 letters, digits and underscores, unique in any
|
||||
case. A page writes it in its stored case, and another case still resolves.
|
||||
Users can't rename themselves: those with `user:manage` rename anyone, from
|
||||
changes only when someone edits it: the edit forms propose `slugify(newTitle)`
|
||||
when the title changes. Its owner may change it 3 times a day; moderators
|
||||
(`dump:moderate`, `playlist:moderate`) may edit any slug, at any time.
|
||||
- **A username** is 1 to 32 letters, digits and underscores, unique in any case.
|
||||
A page writes it in its stored case, and another case still resolves. Users
|
||||
can't rename themselves: those with `user:manage` rename anyone, from
|
||||
`/@<name>/manage`.
|
||||
- **A user from another instance** is `name@host` here: their name at home and
|
||||
their home's host, with its port if it has one (`alice@music.example`). Their
|
||||
name is their home's to change, which leaves the old handle an alias here too.
|
||||
Mentions write the whole handle.
|
||||
- **Aliases.** A slug or a name left behind stays an alias for good, which
|
||||
always names the item or user itself, so redirects never chain. Nobody else
|
||||
can take one. An item can take back one of its own. Deleting an item frees
|
||||
its aliases.
|
||||
- **Redirects.** A page reached by an alias, or by a name in another case,
|
||||
gets the item from the API under its current path and replaces its URL.
|
||||
The server answers the same path with a 301 to crawlers and full page loads.
|
||||
can take one. An item can take back one of its own. Deleting an item frees its
|
||||
aliases.
|
||||
- **Redirects.** A page reached by an alias, or by a name in another case, gets
|
||||
the item from the API under its current path and replaces its URL. The server
|
||||
answers the same path with a 301 to crawlers and full page loads.
|
||||
- **Reserved words.** A category can't take a word the root already uses:
|
||||
`hot new journal follow search login register notifications reset-password
|
||||
unsubscribe settings manage community api ws assets emojibase`
|
||||
(`api/lib/reserved-slugs.ts`). Dump and playlist slugs reserve nothing, and
|
||||
no slug has an `@`.
|
||||
unsubscribe settings manage community authorize federation api ws assets
|
||||
emojibase`
|
||||
(`api/lib/reserved-slugs.ts`). Dump and playlist slugs reserve nothing, and no
|
||||
slug has an `@`.
|
||||
|
||||
## API
|
||||
|
||||
Every JSON endpoint answers the envelope of `shared/api.ts`:
|
||||
`{ "success": true, "data": … }`, or `{ "success": false, "error": { "code",
|
||||
"message" } }` with the matching HTTP status. A list answers
|
||||
`{ "items", "hasMore" }` and takes `?page=` (from 1) and `?limit=` (20 by
|
||||
default, 100 at most). "Signed in" means a session token as
|
||||
`{ "success": true, "data": … }`, or
|
||||
`{ "success": false, "error": { "code",
|
||||
"message" } }` with the matching HTTP
|
||||
status. A list answers `{ "items", "hasMore" }` and takes `?page=` (from 1) and
|
||||
`?limit=` (20 by default, 100 at most). "Signed in" means a session token as
|
||||
`Authorization: Bearer …`. Media can't send a header, so a private file,
|
||||
thumbnail, waveform, cover or attachment takes the owner's media token as
|
||||
`?token=`.
|
||||
@@ -89,19 +97,25 @@ thumbnail, waveform, cover or attachment takes the owner's media token as
|
||||
- `PATCH /api/me` — signed in. Changes the description and email.
|
||||
- `GET /api/me/media-token` — signed in. A media token.
|
||||
- `GET`, `PATCH /api/me/settings` — signed in. The caller's settings.
|
||||
- `PUT /api/me/password` — signed in, with the current one. A new password,
|
||||
and fresh tokens.
|
||||
- `PUT /api/me/password` — signed in, with the current one. A new password, and
|
||||
fresh tokens.
|
||||
- `PUT /api/me/avatar` — signed in. A new avatar (multipart).
|
||||
- `GET /api/me/playlists?contains=:dumpId` — signed in. The caller's
|
||||
playlists, and whether each holds the dump.
|
||||
- `GET /api/me/follow` — signed in. The ids of the users and playlists
|
||||
followed.
|
||||
- `GET /api/me/playlists?contains=:dumpId` — signed in. The caller's playlists,
|
||||
and whether each holds the dump.
|
||||
- `GET /api/me/follow` — signed in. The ids of the users and playlists followed.
|
||||
- `PUT`, `DELETE /api/me/follow/users/:id`, `/api/me/follow/playlists/:id` —
|
||||
signed in. Follows or stops following.
|
||||
- `GET /api/me/feed?from=users|playlists&category=` — signed in. The dumps of
|
||||
what the caller follows.
|
||||
- `GET /api/me/notifications` — signed in. The caller's notifications.
|
||||
- `POST /api/me/notifications/read` — signed in. Marks them all read.
|
||||
- `GET /api/me/grants` — signed in. The other instances the caller's account
|
||||
signs in to.
|
||||
- `DELETE /api/me/grants/:host` — signed in. Withdraws one, ending the caller's
|
||||
sessions there.
|
||||
|
||||
An account from another instance changes its password, email, avatar and name
|
||||
there: those endpoints answer it 400.
|
||||
|
||||
### users
|
||||
|
||||
@@ -109,10 +123,10 @@ thumbnail, waveform, cover or attachment takes the owner's media token as
|
||||
- `GET /api/users?q=` — anyone. Up to 8 users whose name starts with `q`.
|
||||
- `GET /api/users/:key` — anyone. A profile.
|
||||
- `GET /api/users/:key/avatar` — anyone. The avatar.
|
||||
- `GET /api/users/:key/dumps`, `/upvoted`, `/playlists` — anyone; private
|
||||
items to their owner only. Their lists.
|
||||
- `GET /api/users/:key/dumps/:slug`, `/playlists/:slug` — anyone; private
|
||||
items to their owner only. An item by its page path, by an alias too.
|
||||
- `GET /api/users/:key/dumps`, `/upvoted`, `/playlists` — anyone; private items
|
||||
to their owner only. Their lists.
|
||||
- `GET /api/users/:key/dumps/:slug`, `/playlists/:slug` — anyone; private items
|
||||
to their owner only. An item by its page path, by an alias too.
|
||||
- `GET /api/users/:key/follow/users`, `/follow/playlists` — anyone. What they
|
||||
follow.
|
||||
- `GET /api/users/:key/invitees` — anyone. The tree of users they invited.
|
||||
@@ -125,16 +139,16 @@ thumbnail, waveform, cover or attachment takes the owner's media token as
|
||||
- `GET /api/dumps` — anyone. The feed; `?category=<slug>` narrows it, and
|
||||
`?url=` answers the dumps of the same link instead.
|
||||
- `GET /api/dumps/:id` — anyone who may see it. A dump.
|
||||
- `GET /api/dumps/:id/voters`, `/related`, `/playlists` — the same. Who
|
||||
upvoted it, the dumps linked to or from it, the playlists holding it.
|
||||
- `GET /api/dumps/:id/voters`, `/related`, `/playlists` — the same. Who upvoted
|
||||
it, the dumps linked to or from it, the playlists holding it.
|
||||
- `PUT /api/dumps/:id` — its poster, `dump:moderate`. Title, slug, note,
|
||||
visibility, categories, link.
|
||||
- `POST /api/dumps/:id/metadata` — the same. Fetches a link's preview again.
|
||||
- `DELETE /api/dumps/:id` — the same. Deletes it.
|
||||
- `GET`, `PUT /api/dumps/:id/file` — the file; a new one, by its poster or
|
||||
`dump:moderate`.
|
||||
- `GET`, `PUT`, `DELETE /api/dumps/:id/thumbnail` — the thumbnail; a custom
|
||||
one, set or removed by its poster or `dump:moderate`.
|
||||
- `GET`, `PUT`, `DELETE /api/dumps/:id/thumbnail` — the thumbnail; a custom one,
|
||||
set or removed by its poster or `dump:moderate`.
|
||||
- `GET /api/dumps/:id/waveform` — anyone who may see it. An audio file's
|
||||
waveform, `{ "peaks" }`, for the player to seek on.
|
||||
- `GET`, `POST /api/dumps/:id/comments` — anyone who may see the dump; posting
|
||||
@@ -144,18 +158,18 @@ thumbnail, waveform, cover or attachment takes the owner's media token as
|
||||
|
||||
- `POST /api/playlists` — signed in. Creates a playlist.
|
||||
- `GET /api/playlists/:id` — anyone who may see it. A playlist and its dumps.
|
||||
- `PUT`, `DELETE /api/playlists/:id` — its owner, `playlist:moderate`.
|
||||
Title, slug, description, visibility; deletion.
|
||||
- `POST`, `DELETE /api/playlists/:id/dumps/:dumpId` — the same. Adds or
|
||||
removes a dump.
|
||||
- `PUT`, `DELETE /api/playlists/:id` — its owner, `playlist:moderate`. Title,
|
||||
slug, description, visibility; deletion.
|
||||
- `POST`, `DELETE /api/playlists/:id/dumps/:dumpId` — the same. Adds or removes
|
||||
a dump.
|
||||
- `PUT /api/playlists/:id/order` — the same. Reorders its dumps.
|
||||
- `GET`, `PUT /api/playlists/:id/cover` — the cover; a new one, by its owner
|
||||
or `playlist:moderate`.
|
||||
- `GET`, `PUT /api/playlists/:id/cover` — the cover; a new one, by its owner or
|
||||
`playlist:moderate`.
|
||||
|
||||
### comments
|
||||
|
||||
- `PATCH`, `DELETE /api/comments/:id` — its author, `comment:moderate`. Edits
|
||||
or deletes a comment.
|
||||
- `PATCH`, `DELETE /api/comments/:id` — its author, `comment:moderate`. Edits or
|
||||
deletes a comment.
|
||||
- `GET /api/comments/:id/likers` — anyone. Who likes it.
|
||||
|
||||
### categories
|
||||
@@ -166,17 +180,17 @@ thumbnail, waveform, cover or attachment takes the owner's media token as
|
||||
|
||||
### community
|
||||
|
||||
- `GET /api/community?period=weekly|monthly` — signed in. The site's top
|
||||
dumps, new members, new public playlists and totals over the last week (by
|
||||
default) or month, as of the server's last refresh.
|
||||
- `GET /api/community?period=weekly|monthly` — signed in. The site's top dumps,
|
||||
new members, new public playlists and totals over the last week (by default)
|
||||
or month, as of the server's last refresh.
|
||||
|
||||
### chat
|
||||
|
||||
- `GET /api/chat?before=&after=` — signed in. A page of messages. The chat
|
||||
itself goes over `/ws`.
|
||||
- A message from a moderator or an admin (`chat:everyone`) that says
|
||||
`@everyone` notifies every user who doesn't have the chat open, and comes
|
||||
flagged `mentionsEveryone`. No account may be named `everyone`.
|
||||
- A message from a moderator or an admin (`chat:everyone`) that says `@everyone`
|
||||
notifies every user who doesn't have the chat open, and comes flagged
|
||||
`mentionsEveryone`. No account may be named `everyone`.
|
||||
|
||||
### invites
|
||||
|
||||
@@ -188,10 +202,36 @@ thumbnail, waveform, cover or attachment takes the owner's media token as
|
||||
- `POST /api/sessions` — anyone. Signs in.
|
||||
- `POST /api/password-resets` — anyone. Emails a reset link.
|
||||
- `PUT /api/password-resets/:token` — with the link's token. A new password.
|
||||
- `POST /api/unsubscribe?token=` — with a digest's token. Turns it off. Its
|
||||
v3 path, `POST /api/users/unsubscribe`, stays for good, since sent emails'
|
||||
- `POST /api/unsubscribe?token=` — with a digest's token. Turns it off. Its v3
|
||||
path, `POST /api/users/unsubscribe`, stays for good, since sent emails'
|
||||
`List-Unsubscribe` headers name it.
|
||||
|
||||
### federation, oauth, .well-known
|
||||
|
||||
Federated sign-in (`shared/federation.ts`): a user signs in here with their
|
||||
account on another gerbeur instance, their home, by OpenID Connect's
|
||||
authorization code flow with PKCE, with tokens signed by each instance's Ed25519
|
||||
key.
|
||||
|
||||
- `GET /.well-known/openid-configuration`, `/.well-known/jwks.json` — anyone.
|
||||
The instance's metadata and public keys, bare JSON: the one exception to the
|
||||
`/api/` rule, at the paths the standard fixes.
|
||||
- `POST /api/federation/logins` — anyone. Starts a sign-in with an account on
|
||||
another instance; one neither trusted nor already home to someone here needs
|
||||
an invite.
|
||||
- `POST /api/federation/sessions` — anyone. Finishes it, as a sign-in.
|
||||
- `POST /api/federation/events` — another instance. Its signed news of one of
|
||||
its users here: a new name, email or avatar, or the end of their sessions.
|
||||
- `GET /api/federation/peers` — anyone the trusted instances,
|
||||
`federation:manage` every listed one.
|
||||
- `PUT /api/federation/peers`, `DELETE /api/federation/peers/:host` —
|
||||
`federation:manage`. Trusts or blocks an instance, or takes it off the lists.
|
||||
- `GET /api/oauth/clients?client_id=` — signed in. The instance asking to sign
|
||||
the caller in.
|
||||
- `POST /api/oauth/codes` — signed in. The caller's consent, and a code.
|
||||
- `POST /api/oauth/tokens` — another instance, form-encoded. Trades a code for
|
||||
an identity token, bare JSON.
|
||||
|
||||
### search
|
||||
|
||||
- `GET /api/search?q=` — anyone. A page of dumps, and up to 20 users and
|
||||
@@ -211,5 +251,5 @@ thumbnail, waveform, cover or attachment takes the owner's media token as
|
||||
|
||||
### /ws
|
||||
|
||||
- `GET /ws?token=` — anyone; a session token signs it in. The WebSocket of
|
||||
live updates and the chat (`shared/ws.ts`).
|
||||
- `GET /ws?token=` — anyone; a session token signs it in. The WebSocket of live
|
||||
updates and the chat (`shared/ws.ts`).
|
||||
|
||||
Reference in New Issue
Block a user