| 1 | # Shaer to Klonkt: the C2S API
|
|---|
| 2 |
|
|---|
| 3 | This documents the contract the Shaer apps (iOS, Android) use to drive a Klonkt
|
|---|
| 4 | account. It is ActivityPub Client-to-Server (C2S) over OAuth 2.0, plus a few
|
|---|
| 5 | `shaer:` extension terms from FEP-633c. It is not the Mastodon client API:
|
|---|
| 6 | Mastodon apps use their own API and are not served here.
|
|---|
| 7 |
|
|---|
| 8 | Everything is discovered, never hardcoded: a client resolves a handle to an
|
|---|
| 9 | actor document and reads the endpoints from it, so the same client works against
|
|---|
| 10 | any Klonkt instance (and degrades gracefully against other AP servers).
|
|---|
| 11 |
|
|---|
| 12 | Base URL below is the instance origin, e.g. `https://klonkt.example`. All AP
|
|---|
| 13 | requests send and accept `application/activity+json`.
|
|---|
| 14 |
|
|---|
| 15 | ## 1. Discovery
|
|---|
| 16 |
|
|---|
| 17 | 1. **WebFinger** the handle:
|
|---|
| 18 | `GET /.well-known/webfinger?resource=acct:<user>@<host>`
|
|---|
| 19 | Returns a JRD; the `self` link (`type: application/activity+json`) is the
|
|---|
| 20 | actor id.
|
|---|
| 21 | 2. **Fetch the actor** at that id: `GET /ap/users/:slug`.
|
|---|
| 22 | Read the collection URLs and the C2S endpoints from it, never construct them:
|
|---|
| 23 |
|
|---|
| 24 | ```json
|
|---|
| 25 | {
|
|---|
| 26 | "id": "https://klonkt.example/ap/users/robin",
|
|---|
| 27 | "type": "Person",
|
|---|
| 28 | "preferredUsername": "robin",
|
|---|
| 29 | "name": "Robin",
|
|---|
| 30 | "inbox": ".../inbox",
|
|---|
| 31 | "outbox": ".../outbox",
|
|---|
| 32 | "followers": ".../followers",
|
|---|
| 33 | "following": ".../following",
|
|---|
| 34 | "endpoints": {
|
|---|
| 35 | "oauthAuthorizationEndpoint": "https://klonkt.example/oauth/authorize",
|
|---|
| 36 | "oauthTokenEndpoint": "https://klonkt.example/oauth/token",
|
|---|
| 37 | "uploadMedia": ".../uploadMedia"
|
|---|
| 38 | }
|
|---|
| 39 | }
|
|---|
| 40 | ```
|
|---|
| 41 |
|
|---|
| 42 | 3. **Server metadata** (RFC 8414):
|
|---|
| 43 | `GET /.well-known/oauth-authorization-server` returns
|
|---|
| 44 | `authorization_endpoint`, `token_endpoint`, `registration_endpoint`,
|
|---|
| 45 | `code_challenge_methods_supported: ["S256"]`,
|
|---|
| 46 | `token_endpoint_auth_methods_supported: ["none"]`, `scopes_supported: ["c2s"]`.
|
|---|
| 47 |
|
|---|
| 48 | If the actor lacks the OAuth endpoints, the server does not support posting from
|
|---|
| 49 | apps; the client should degrade to read-only.
|
|---|
| 50 |
|
|---|
| 51 | ## 2. Authentication (OAuth 2.0, public client + PKCE)
|
|---|
| 52 |
|
|---|
| 53 | Public clients only. No client secret. PKCE with S256 is required.
|
|---|
| 54 |
|
|---|
| 55 | 1. **Register** (RFC 7591, once per install):
|
|---|
| 56 | `POST /oauth/register` with `{ "client_name": "...", "redirect_uris": ["com.klonkt.shaer:/oauth"] }`
|
|---|
| 57 | returns `201 { "client_id": "..." }`. The redirect scheme must contain a dot
|
|---|
| 58 | (reverse-DNS custom scheme).
|
|---|
| 59 | 2. **Authorize**: open `authorization_endpoint` in a system browser with
|
|---|
| 60 | `response_type=code`, `client_id`, `redirect_uri`, `code_challenge`,
|
|---|
| 61 | `code_challenge_method=S256`, `scope=c2s`, `state`. The user logs into Klonkt
|
|---|
| 62 | and picks which of their sites the app may post as. For a non-http
|
|---|
| 63 | redirect_uri Klonkt serves a small interstitial that forwards to the custom
|
|---|
| 64 | scheme (mobile browsers drop a bare 302 to a custom scheme); web clients get a
|
|---|
| 65 | 302.
|
|---|
| 66 | 3. **Token**: `POST /oauth/token` (form-encoded) with
|
|---|
| 67 | `grant_type=authorization_code`, `code`, `client_id`, `redirect_uri`,
|
|---|
| 68 | `code_verifier`. Returns `{ "access_token": "...", "token_type": "Bearer" }`.
|
|---|
| 69 |
|
|---|
| 70 | The bearer is scoped to one user and one site. Send it as
|
|---|
| 71 | `Authorization: Bearer <token>` on every authenticated call. Tokens are stored
|
|---|
| 72 | hashed server-side; the client keeps the bearer in the platform keystore. A
|
|---|
| 73 | `401` means the token is dead: re-run the flow.
|
|---|
| 74 |
|
|---|
| 75 | ## 3. Reads (bearer, owner only)
|
|---|
| 76 |
|
|---|
| 77 | | Call | Returns |
|
|---|
| 78 | | --- | --- |
|
|---|
| 79 | | `GET /ap/users/:slug` | the actor document (add the bearer to also read owner-only fields) |
|
|---|
| 80 | | `GET /ap/users/:slug/outbox` | OrderedCollection of the account's own `Create(Note)` |
|
|---|
| 81 | | `GET /ap/users/:slug/inbox` | OrderedCollection of recent inbound posts (accounts you follow) as `Create(Note)`. Owner only; `403` for anyone else. The unified home feed is outbox + inbox merged. |
|
|---|
| 82 |
|
|---|
| 83 | ### Waiting for news on the inbox read
|
|---|
| 84 |
|
|---|
| 85 | The inbox read can hold the answer until there is something new, so a client does
|
|---|
| 86 | not have to poll. It is the **same call and the same response** — no separate
|
|---|
| 87 | endpoint and no separate "there is news" shape, because a second shape is a
|
|---|
| 88 | second description of a post that can drift from the first.
|
|---|
| 89 |
|
|---|
| 90 | ```
|
|---|
| 91 | GET /ap/users/:slug/inbox?since=<cursor>&wait=25
|
|---|
| 92 | ```
|
|---|
| 93 |
|
|---|
| 94 | - `since` is the `shaer:cursor` from your previous answer. Treat it as opaque.
|
|---|
| 95 | - `wait` is seconds, capped at 50 — well under what a proxy will hold.
|
|---|
| 96 | - Send neither and the route behaves exactly as it always did.
|
|---|
| 97 |
|
|---|
| 98 | Two possible answers:
|
|---|
| 99 |
|
|---|
| 100 | | | |
|
|---|
| 101 | |---|---|
|
|---|
| 102 | | **`200`** | something changed. The full, current collection with a fresh `shaer:cursor`. |
|
|---|
| 103 | | **`304`** | nothing changed within `wait`. **No body.** Keep the cursor you have and ask again. |
|
|---|
| 104 |
|
|---|
| 105 | `304` is not an error — it is the normal answer to a quiet minute, and it is why
|
|---|
| 106 | this costs nothing while nothing happens. Sending the whole timeline back every
|
|---|
| 107 | `wait` seconds just to say "still nothing" would be a poor trade for saving one
|
|---|
| 108 | round trip.
|
|---|
| 109 |
|
|---|
| 110 | A server that has not run the feed-state migration yet cannot tell change from
|
|---|
| 111 | stillness, and answers `200` with the collection every time rather than `304`
|
|---|
| 112 | forever. Slower, never wrong.
|
|---|
| 113 |
|
|---|
| 114 | The cursor moves for **all four** sources this read merges: the timeline,
|
|---|
| 115 | messages, replies on your own posts, and your own sent notes.
|
|---|
| 116 |
|
|---|
| 117 | Limits worth knowing as a client author:
|
|---|
| 118 |
|
|---|
| 119 | - four waiting connections per account. A fifth gets the current state
|
|---|
| 120 | immediately rather than an error, so a broken reconnect loop degrades to
|
|---|
| 121 | ordinary polling instead of locking the account out.
|
|---|
| 122 | - hanging up ends the wait; there is no cost to abandoning a request.
|
|---|
| 123 | - the bearer is checked *before* any waiting, so an unauthenticated caller can
|
|---|
| 124 | never hold a connection open.
|
|---|
| 125 |
|
|---|
| 126 | This works while your app is in the foreground, and only there. iOS freezes
|
|---|
| 127 | network activity the moment an app is backgrounded, and a held-open connection
|
|---|
| 128 | does not survive it.
|
|---|
| 129 |
|
|---|
| 130 | **There is no background channel to fall back on for a native app.** Klonkt's
|
|---|
| 131 | web push uses VAPID, which a self-hosted instance can generate and send entirely
|
|---|
| 132 | on its own — but only to a browser or an installed PWA. A native app receives
|
|---|
| 133 | push through APNs (or FCM), signed with the *application's* key, which a
|
|---|
| 134 | self-hosted Klonkt does not have and should not have: whoever holds it can push
|
|---|
| 135 | to every user of that app. Closing that gap needs a relay run by the app's
|
|---|
| 136 | publisher, and that is a decision about money and control, not a protocol
|
|---|
| 137 | detail.
|
|---|
| 138 |
|
|---|
| 139 | So for a self-hosted setup this is not a stopgap until push arrives. For now it
|
|---|
| 140 | is the freshness channel.
|
|---|
| 141 | | `GET /ap/users/:slug/followers` | see below |
|
|---|
| 142 | | `GET /ap/users/:slug/following` | see below |
|
|---|
| 143 |
|
|---|
| 144 | **Followers and following** are count-only for the public (privacy). With the
|
|---|
| 145 | owner's bearer they return the real entries. By default these are bare id
|
|---|
| 146 | strings; send `Prefer: return=representation` (FEP-9876) to get them enriched as
|
|---|
| 147 | AS2 actor references with display, so a client shows names and avatars instead of
|
|---|
| 148 | bare ids. The server echoes `Preference-Applied: return=representation` and sets
|
|---|
| 149 | `Vary: Prefer`:
|
|---|
| 150 |
|
|---|
| 151 | ```json
|
|---|
| 152 | {
|
|---|
| 153 | "type": "OrderedCollection",
|
|---|
| 154 | "totalItems": 2,
|
|---|
| 155 | "orderedItems": [
|
|---|
| 156 | { "id": "https://r.example/users/anna", "type": "Person",
|
|---|
| 157 | "name": "Anna", "preferredUsername": "anna",
|
|---|
| 158 | "icon": { "type": "Image", "url": "https://r.example/anna.png" } }
|
|---|
| 159 | ]
|
|---|
| 160 | }
|
|---|
| 161 | ```
|
|---|
| 162 |
|
|---|
| 163 | Display priority for a contact is `name`, then `preferredUsername`, then a handle
|
|---|
| 164 | derived from the id. Entries may also arrive as bare id strings (other servers,
|
|---|
| 165 | or entries with no cached display); a client handles both shapes.
|
|---|
| 166 |
|
|---|
| 167 | ## 4. Writes: `POST /ap/users/:slug/outbox` (bearer)
|
|---|
| 168 |
|
|---|
| 169 | Post an Activity, or a bare object which the server wraps in a `Create` per the
|
|---|
| 170 | AP spec. Supported: `Create` (Note), `Like`, `Announce`, `Follow`, and `Undo` of
|
|---|
| 171 | `Follow` / `Like` / `Announce`. `Delete` and `Update` over C2S are not yet
|
|---|
| 172 | implemented.
|
|---|
| 173 |
|
|---|
| 174 | A `Note` carries `content` (HTML) and a `source` object with the plain text:
|
|---|
| 175 |
|
|---|
| 176 | ```json
|
|---|
| 177 | { "type": "Note",
|
|---|
| 178 | "content": "<p>hoi</p>",
|
|---|
| 179 | "source": { "content": "hoi", "mediaType": "text/plain" } }
|
|---|
| 180 | ```
|
|---|
| 181 |
|
|---|
| 182 | ### 4.1 Visibility (addressing)
|
|---|
| 183 |
|
|---|
| 184 | Visibility comes from the note's `to` / `cc`, the Mastodon model. There is no
|
|---|
| 185 | separate flag.
|
|---|
| 186 |
|
|---|
| 187 | | App choice | Addressing | Result |
|
|---|
| 188 | | --- | --- | --- |
|
|---|
| 189 | | Public | `to: [as:Public]` | public, boostable, in timelines |
|
|---|
| 190 | | Quiet public | `to: [<followers>]`, `cc: [as:Public]` | unlisted |
|
|---|
| 191 | | Friends | `to: [<followers>]` | followers-only, not boostable |
|
|---|
| 192 | | Participants only | `to: [<actor uris>]`, no Public | a private mention (direct message), see 4.2 |
|
|---|
| 193 |
|
|---|
| 194 | `as:Public` is `https://www.w3.org/ns/activitystreams#Public`. `<followers>` is
|
|---|
| 195 | the actor's followers collection URL. No addressing at all is treated as public
|
|---|
| 196 | (legacy).
|
|---|
| 197 |
|
|---|
| 198 | ### 4.2 Direct notes (private mentions)
|
|---|
| 199 |
|
|---|
| 200 | A note addressed only to actor URIs (no Public, no followers) is a direct
|
|---|
| 201 | message, not a post. Klonkt delivers it S2S to exactly those inboxes: no
|
|---|
| 202 | followers fan-out, empty `cc`, so it can never be boosted or appear in a
|
|---|
| 203 | timeline. A guardian on any AP server (even plain Mastodon) receives it as a
|
|---|
| 204 | private mention. Address the recipients as `Mention` tags so their servers
|
|---|
| 205 | notify them. A direct note with no resolvable recipient is refused
|
|---|
| 206 | (`400 no_recipients`).
|
|---|
| 207 |
|
|---|
| 208 | ### 4.3 Attachments
|
|---|
| 209 |
|
|---|
| 210 | Put AS2 `attachment` items on the note (`Image` / `Document` with `url`,
|
|---|
| 211 | `mediaType`, `name`). Upload first (section 5); Klonkt accepts only its own
|
|---|
| 212 | `/media/...` URLs, image/audio/video, up to 4.
|
|---|
| 213 |
|
|---|
| 214 | ### 4.4 Help request (FEP-633c)
|
|---|
| 215 |
|
|---|
| 216 | A ward's call for help is a direct note carrying `"shaer:helpRequest": true`,
|
|---|
| 217 | addressed to all its guardians, optionally with a capture as an `attachment`, the
|
|---|
| 218 | subject as an FEP-e232 object link (never a `Mention`, which would add the
|
|---|
| 219 | subject to the conversation), and a short text quote. Guardian-side clients may
|
|---|
| 220 | render it as an alert; other servers read a normal private mention. See FEP-633c
|
|---|
| 221 | 5.2.1.
|
|---|
| 222 |
|
|---|
| 223 | ### 4.5 Hardening
|
|---|
| 224 |
|
|---|
| 225 | - A `Like` or `Announce` of a non-public local post returns `403 not_public`.
|
|---|
| 226 | - An inbound boost or like of a non-public post is dropped, not stored.
|
|---|
| 227 |
|
|---|
| 228 | ## 5. Media upload: `POST /ap/users/:slug/uploadMedia` (bearer)
|
|---|
| 229 |
|
|---|
| 230 | `multipart/form-data`, field name `file`, one image/audio/video, up to 32 MB.
|
|---|
| 231 | Returns `201 { "url": "/media/reply-media/...", "mediaType": "image/png", "name": "..." }`.
|
|---|
| 232 | Use `url` in an `attachment` on the next note.
|
|---|
| 233 |
|
|---|
| 234 | ## 6. The `shaer:` vocabulary (FEP-633c)
|
|---|
| 235 |
|
|---|
| 236 | Namespace `https://ns.klonkt.com/shaer#`, declared in the emitted `@context`.
|
|---|
| 237 |
|
|---|
| 238 | | Term | On | Meaning |
|
|---|
| 239 | | --- | --- | --- |
|
|---|
| 240 | | `shaer:guardians` | Actor | array of guardian actor URIs; non-empty marks a ward |
|
|---|
| 241 | | `shaer:isGuardian` | Actor | `true` marks a guardian |
|
|---|
| 242 | | `shaer:hasGuardians` | Object | per-object routing hint that the author is a ward |
|
|---|
| 243 | | `shaer:helpRequest` | Note | marks a direct note as a ward's call for help (4.4) |
|
|---|
| 244 |
|
|---|
| 245 | The full model (the handshake, gating, emancipation, escalation) is FEP-633c in
|
|---|
| 246 | `work/klonkt/fep-633c.md`.
|
|---|
| 247 |
|
|---|
| 248 | ## 7. Error reference
|
|---|
| 249 |
|
|---|
| 250 | | Status | When |
|
|---|
| 251 | | --- | --- |
|
|---|
| 252 | | `400 invalid_activity` / `missing_object` / `empty_note` | malformed write |
|
|---|
| 253 | | `400 no_recipients` | direct note with no resolvable recipient |
|
|---|
| 254 | | `400 unsupported_type` | a verb C2S does not implement (Delete/Update) |
|
|---|
| 255 | | `403` (reads) | not the owner (inbox, owner followers/following) |
|
|---|
| 256 | | `403 not_public` | Like/Announce of a non-public local post |
|
|---|
| 257 | | `401` | dead or missing bearer: re-authenticate |
|
|---|
| 258 | | `502 cannot_resolve_inReplyTo` / `direct_failed` | a delivery target could not be resolved |
|
|---|
| 259 |
|
|---|
| 260 | ## 8. Notes and limits
|
|---|
| 261 |
|
|---|
| 262 | - The enriched followers/following display is a Klonkt convenience. A generic AP
|
|---|
| 263 | server returns bare ids; the client falls back to a derived handle.
|
|---|
| 264 | - This is ActivityPub C2S. There is no Mastodon-compatible client API.
|
|---|
| 265 | - See `FEDERATION.md` for the server-to-server surface (activities, signatures,
|
|---|
| 266 | extension terms, supported FEPs).
|
|---|