# Shaer to Klonkt: the C2S API This documents the contract the Shaer apps (iOS, Android) use to drive a Klonkt account. It is ActivityPub Client-to-Server (C2S) over OAuth 2.0, plus a few `shaer:` extension terms from FEP-633c. It is not the Mastodon client API: Mastodon apps use their own API and are not served here. Everything is discovered, never hardcoded: a client resolves a handle to an actor document and reads the endpoints from it, so the same client works against any Klonkt instance (and degrades gracefully against other AP servers). Base URL below is the instance origin, e.g. `https://klonkt.example`. All AP requests send and accept `application/activity+json`. ## 1. Discovery 1. **WebFinger** the handle: `GET /.well-known/webfinger?resource=acct:@` Returns a JRD; the `self` link (`type: application/activity+json`) is the actor id. 2. **Fetch the actor** at that id: `GET /ap/users/:slug`. Read the collection URLs and the C2S endpoints from it, never construct them: ```json { "id": "https://klonkt.example/ap/users/robin", "type": "Person", "preferredUsername": "robin", "name": "Robin", "inbox": ".../inbox", "outbox": ".../outbox", "followers": ".../followers", "following": ".../following", "endpoints": { "oauthAuthorizationEndpoint": "https://klonkt.example/oauth/authorize", "oauthTokenEndpoint": "https://klonkt.example/oauth/token", "uploadMedia": ".../uploadMedia" } } ``` 3. **Server metadata** (RFC 8414): `GET /.well-known/oauth-authorization-server` returns `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, `code_challenge_methods_supported: ["S256"]`, `token_endpoint_auth_methods_supported: ["none"]`, `scopes_supported: ["c2s"]`. If the actor lacks the OAuth endpoints, the server does not support posting from apps; the client should degrade to read-only. ## 2. Authentication (OAuth 2.0, public client + PKCE) Public clients only. No client secret. PKCE with S256 is required. 1. **Register** (RFC 7591, once per install): `POST /oauth/register` with `{ "client_name": "...", "redirect_uris": ["com.klonkt.shaer:/oauth"] }` returns `201 { "client_id": "..." }`. The redirect scheme must contain a dot (reverse-DNS custom scheme). 2. **Authorize**: open `authorization_endpoint` in a system browser with `response_type=code`, `client_id`, `redirect_uri`, `code_challenge`, `code_challenge_method=S256`, `scope=c2s`, `state`. The user logs into Klonkt and picks which of their sites the app may post as. For a non-http redirect_uri Klonkt serves a small interstitial that forwards to the custom scheme (mobile browsers drop a bare 302 to a custom scheme); web clients get a 302. 3. **Token**: `POST /oauth/token` (form-encoded) with `grant_type=authorization_code`, `code`, `client_id`, `redirect_uri`, `code_verifier`. Returns `{ "access_token": "...", "token_type": "Bearer" }`. The bearer is scoped to one user and one site. Send it as `Authorization: Bearer ` on every authenticated call. Tokens are stored hashed server-side; the client keeps the bearer in the platform keystore. A `401` means the token is dead: re-run the flow. ## 3. Reads (bearer, owner only) | Call | Returns | | --- | --- | | `GET /ap/users/:slug` | the actor document (add the bearer to also read owner-only fields) | | `GET /ap/users/:slug/outbox` | OrderedCollection of the account's own `Create(Note)` | | `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. | ### Waiting for news on the inbox read The inbox read can hold the answer until there is something new, so a client does not have to poll. It is the **same call and the same response** — no separate endpoint and no separate "there is news" shape, because a second shape is a second description of a post that can drift from the first. ``` GET /ap/users/:slug/inbox?since=&wait=25 ``` - `since` is the `shaer:cursor` from your previous answer. Treat it as opaque. - `wait` is seconds, capped at 50 — well under what a proxy will hold. - Send neither and the route behaves exactly as it always did. Two possible answers: | | | |---|---| | **`200`** | something changed. The full, current collection with a fresh `shaer:cursor`. | | **`304`** | nothing changed within `wait`. **No body.** Keep the cursor you have and ask again. | `304` is not an error — it is the normal answer to a quiet minute, and it is why this costs nothing while nothing happens. Sending the whole timeline back every `wait` seconds just to say "still nothing" would be a poor trade for saving one round trip. A server that has not run the feed-state migration yet cannot tell change from stillness, and answers `200` with the collection every time rather than `304` forever. Slower, never wrong. The cursor moves for **all four** sources this read merges: the timeline, messages, replies on your own posts, and your own sent notes. Limits worth knowing as a client author: - four waiting connections per account. A fifth gets the current state immediately rather than an error, so a broken reconnect loop degrades to ordinary polling instead of locking the account out. - hanging up ends the wait; there is no cost to abandoning a request. - the bearer is checked *before* any waiting, so an unauthenticated caller can never hold a connection open. This works while your app is in the foreground, and only there. iOS freezes network activity the moment an app is backgrounded, and a held-open connection does not survive it. **There is no background channel to fall back on for a native app.** Klonkt's web push uses VAPID, which a self-hosted instance can generate and send entirely on its own — but only to a browser or an installed PWA. A native app receives push through APNs (or FCM), signed with the *application's* key, which a self-hosted Klonkt does not have and should not have: whoever holds it can push to every user of that app. Closing that gap needs a relay run by the app's publisher, and that is a decision about money and control, not a protocol detail. So for a self-hosted setup this is not a stopgap until push arrives. For now it is the freshness channel. | `GET /ap/users/:slug/followers` | see below | | `GET /ap/users/:slug/following` | see below | **Followers and following** are count-only for the public (privacy). With the owner's bearer they return the real entries. By default these are bare id strings; send `Prefer: return=representation` (FEP-9876) to get them enriched as AS2 actor references with display, so a client shows names and avatars instead of bare ids. The server echoes `Preference-Applied: return=representation` and sets `Vary: Prefer`: ```json { "type": "OrderedCollection", "totalItems": 2, "orderedItems": [ { "id": "https://r.example/users/anna", "type": "Person", "name": "Anna", "preferredUsername": "anna", "icon": { "type": "Image", "url": "https://r.example/anna.png" } } ] } ``` Display priority for a contact is `name`, then `preferredUsername`, then a handle derived from the id. Entries may also arrive as bare id strings (other servers, or entries with no cached display); a client handles both shapes. ## 4. Writes: `POST /ap/users/:slug/outbox` (bearer) Post an Activity, or a bare object which the server wraps in a `Create` per the AP spec. Supported: `Create` (Note), `Like`, `Announce`, `Follow`, and `Undo` of `Follow` / `Like` / `Announce`. `Delete` and `Update` over C2S are not yet implemented. A `Note` carries `content` (HTML) and a `source` object with the plain text: ```json { "type": "Note", "content": "

hoi

", "source": { "content": "hoi", "mediaType": "text/plain" } } ``` ### 4.1 Visibility (addressing) Visibility comes from the note's `to` / `cc`, the Mastodon model. There is no separate flag. | App choice | Addressing | Result | | --- | --- | --- | | Public | `to: [as:Public]` | public, boostable, in timelines | | Quiet public | `to: []`, `cc: [as:Public]` | unlisted | | Friends | `to: []` | followers-only, not boostable | | Participants only | `to: []`, no Public | a private mention (direct message), see 4.2 | `as:Public` is `https://www.w3.org/ns/activitystreams#Public`. `` is the actor's followers collection URL. No addressing at all is treated as public (legacy). ### 4.2 Direct notes (private mentions) A note addressed only to actor URIs (no Public, no followers) is a direct message, not a post. Klonkt delivers it S2S to exactly those inboxes: no followers fan-out, empty `cc`, so it can never be boosted or appear in a timeline. A guardian on any AP server (even plain Mastodon) receives it as a private mention. Address the recipients as `Mention` tags so their servers notify them. A direct note with no resolvable recipient is refused (`400 no_recipients`). ### 4.3 Attachments Put AS2 `attachment` items on the note (`Image` / `Document` with `url`, `mediaType`, `name`). Upload first (section 5); Klonkt accepts only its own `/media/...` URLs, image/audio/video, up to 4. ### 4.4 Help request (FEP-633c) A ward's call for help is a direct note carrying `"shaer:helpRequest": true`, addressed to all its guardians, optionally with a capture as an `attachment`, the subject as an FEP-e232 object link (never a `Mention`, which would add the subject to the conversation), and a short text quote. Guardian-side clients may render it as an alert; other servers read a normal private mention. See FEP-633c 5.2.1. ### 4.5 Hardening - A `Like` or `Announce` of a non-public local post returns `403 not_public`. - An inbound boost or like of a non-public post is dropped, not stored. ## 5. Media upload: `POST /ap/users/:slug/uploadMedia` (bearer) `multipart/form-data`, field name `file`, one image/audio/video, up to 32 MB. Returns `201 { "url": "/media/reply-media/...", "mediaType": "image/png", "name": "..." }`. Use `url` in an `attachment` on the next note. ## 6. The `shaer:` vocabulary (FEP-633c) Namespace `https://ns.klonkt.com/shaer#`, declared in the emitted `@context`. | Term | On | Meaning | | --- | --- | --- | | `shaer:guardians` | Actor | array of guardian actor URIs; non-empty marks a ward | | `shaer:isGuardian` | Actor | `true` marks a guardian | | `shaer:hasGuardians` | Object | per-object routing hint that the author is a ward | | `shaer:helpRequest` | Note | marks a direct note as a ward's call for help (4.4) | The full model (the handshake, gating, emancipation, escalation) is FEP-633c in `work/klonkt/fep-633c.md`. ## 7. Error reference | Status | When | | --- | --- | | `400 invalid_activity` / `missing_object` / `empty_note` | malformed write | | `400 no_recipients` | direct note with no resolvable recipient | | `400 unsupported_type` | a verb C2S does not implement (Delete/Update) | | `403` (reads) | not the owner (inbox, owner followers/following) | | `403 not_public` | Like/Announce of a non-public local post | | `401` | dead or missing bearer: re-authenticate | | `502 cannot_resolve_inReplyTo` / `direct_failed` | a delivery target could not be resolved | ## 8. Notes and limits - The enriched followers/following display is a Klonkt convenience. A generic AP server returns bare ids; the client falls back to a derived handle. - This is ActivityPub C2S. There is no Mastodon-compatible client API. - See `FEDERATION.md` for the server-to-server surface (activities, signatures, extension terms, supported FEPs).