| 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 | | `GET /ap/users/:slug/followers` | see below |
|
|---|
| 83 | | `GET /ap/users/:slug/following` | see below |
|
|---|
| 84 |
|
|---|
| 85 | **Followers and following** are count-only for the public (privacy). With the
|
|---|
| 86 | owner's bearer they return the real entries as AS2 actor references with display,
|
|---|
| 87 | so a client shows names and avatars instead of bare ids:
|
|---|
| 88 |
|
|---|
| 89 | ```json
|
|---|
| 90 | {
|
|---|
| 91 | "type": "OrderedCollection",
|
|---|
| 92 | "totalItems": 2,
|
|---|
| 93 | "orderedItems": [
|
|---|
| 94 | { "id": "https://r.example/users/anna", "type": "Person",
|
|---|
| 95 | "name": "Anna", "preferredUsername": "anna",
|
|---|
| 96 | "icon": { "type": "Image", "url": "https://r.example/anna.png" } }
|
|---|
| 97 | ]
|
|---|
| 98 | }
|
|---|
| 99 | ```
|
|---|
| 100 |
|
|---|
| 101 | Display priority for a contact is `name`, then `preferredUsername`, then a handle
|
|---|
| 102 | derived from the id. Entries may also arrive as bare id strings (other servers,
|
|---|
| 103 | or entries with no cached display); a client handles both shapes.
|
|---|
| 104 |
|
|---|
| 105 | ## 4. Writes: `POST /ap/users/:slug/outbox` (bearer)
|
|---|
| 106 |
|
|---|
| 107 | Post an Activity, or a bare object which the server wraps in a `Create` per the
|
|---|
| 108 | AP spec. Supported: `Create` (Note), `Like`, `Announce`, `Follow`, and `Undo` of
|
|---|
| 109 | `Follow` / `Like` / `Announce`. `Delete` and `Update` over C2S are not yet
|
|---|
| 110 | implemented.
|
|---|
| 111 |
|
|---|
| 112 | A `Note` carries `content` (HTML) and a `source` object with the plain text:
|
|---|
| 113 |
|
|---|
| 114 | ```json
|
|---|
| 115 | { "type": "Note",
|
|---|
| 116 | "content": "<p>hoi</p>",
|
|---|
| 117 | "source": { "content": "hoi", "mediaType": "text/plain" } }
|
|---|
| 118 | ```
|
|---|
| 119 |
|
|---|
| 120 | ### 4.1 Visibility (addressing)
|
|---|
| 121 |
|
|---|
| 122 | Visibility comes from the note's `to` / `cc`, the Mastodon model. There is no
|
|---|
| 123 | separate flag.
|
|---|
| 124 |
|
|---|
| 125 | | App choice | Addressing | Result |
|
|---|
| 126 | | --- | --- | --- |
|
|---|
| 127 | | Public | `to: [as:Public]` | public, boostable, in timelines |
|
|---|
| 128 | | Quiet public | `to: [<followers>]`, `cc: [as:Public]` | unlisted |
|
|---|
| 129 | | Friends | `to: [<followers>]` | followers-only, not boostable |
|
|---|
| 130 | | Participants only | `to: [<actor uris>]`, no Public | a private mention (direct message), see 4.2 |
|
|---|
| 131 |
|
|---|
| 132 | `as:Public` is `https://www.w3.org/ns/activitystreams#Public`. `<followers>` is
|
|---|
| 133 | the actor's followers collection URL. No addressing at all is treated as public
|
|---|
| 134 | (legacy).
|
|---|
| 135 |
|
|---|
| 136 | ### 4.2 Direct notes (private mentions)
|
|---|
| 137 |
|
|---|
| 138 | A note addressed only to actor URIs (no Public, no followers) is a direct
|
|---|
| 139 | message, not a post. Klonkt delivers it S2S to exactly those inboxes: no
|
|---|
| 140 | followers fan-out, empty `cc`, so it can never be boosted or appear in a
|
|---|
| 141 | timeline. A guardian on any AP server (even plain Mastodon) receives it as a
|
|---|
| 142 | private mention. Address the recipients as `Mention` tags so their servers
|
|---|
| 143 | notify them. A direct note with no resolvable recipient is refused
|
|---|
| 144 | (`400 no_recipients`).
|
|---|
| 145 |
|
|---|
| 146 | ### 4.3 Attachments
|
|---|
| 147 |
|
|---|
| 148 | Put AS2 `attachment` items on the note (`Image` / `Document` with `url`,
|
|---|
| 149 | `mediaType`, `name`). Upload first (section 5); Klonkt accepts only its own
|
|---|
| 150 | `/media/...` URLs, image/audio/video, up to 4.
|
|---|
| 151 |
|
|---|
| 152 | ### 4.4 Help request (FEP-633c)
|
|---|
| 153 |
|
|---|
| 154 | A ward's call for help is a direct note carrying `"shaer:helpRequest": true`,
|
|---|
| 155 | addressed to all its guardians, optionally with a capture as an `attachment`, the
|
|---|
| 156 | subject as an FEP-e232 object link (never a `Mention`, which would add the
|
|---|
| 157 | subject to the conversation), and a short text quote. Guardian-side clients may
|
|---|
| 158 | render it as an alert; other servers read a normal private mention. See FEP-633c
|
|---|
| 159 | 5.2.1.
|
|---|
| 160 |
|
|---|
| 161 | ### 4.5 Hardening
|
|---|
| 162 |
|
|---|
| 163 | - A `Like` or `Announce` of a non-public local post returns `403 not_public`.
|
|---|
| 164 | - An inbound boost or like of a non-public post is dropped, not stored.
|
|---|
| 165 |
|
|---|
| 166 | ## 5. Media upload: `POST /ap/users/:slug/uploadMedia` (bearer)
|
|---|
| 167 |
|
|---|
| 168 | `multipart/form-data`, field name `file`, one image/audio/video, up to 32 MB.
|
|---|
| 169 | Returns `201 { "url": "/media/reply-media/...", "mediaType": "image/png", "name": "..." }`.
|
|---|
| 170 | Use `url` in an `attachment` on the next note.
|
|---|
| 171 |
|
|---|
| 172 | ## 6. The `shaer:` vocabulary (FEP-633c)
|
|---|
| 173 |
|
|---|
| 174 | Namespace `https://ns.klonkt.com/shaer#`, declared in the emitted `@context`.
|
|---|
| 175 |
|
|---|
| 176 | | Term | On | Meaning |
|
|---|
| 177 | | --- | --- | --- |
|
|---|
| 178 | | `shaer:guardians` | Actor | array of guardian actor URIs; non-empty marks a ward |
|
|---|
| 179 | | `shaer:isGuardian` | Actor | `true` marks a guardian |
|
|---|
| 180 | | `shaer:hasGuardians` | Object | per-object routing hint that the author is a ward |
|
|---|
| 181 | | `shaer:helpRequest` | Note | marks a direct note as a ward's call for help (4.4) |
|
|---|
| 182 |
|
|---|
| 183 | The full model (the handshake, gating, emancipation, escalation) is FEP-633c in
|
|---|
| 184 | `work/klonkt/fep-633c.md`.
|
|---|
| 185 |
|
|---|
| 186 | ## 7. Error reference
|
|---|
| 187 |
|
|---|
| 188 | | Status | When |
|
|---|
| 189 | | --- | --- |
|
|---|
| 190 | | `400 invalid_activity` / `missing_object` / `empty_note` | malformed write |
|
|---|
| 191 | | `400 no_recipients` | direct note with no resolvable recipient |
|
|---|
| 192 | | `400 unsupported_type` | a verb C2S does not implement (Delete/Update) |
|
|---|
| 193 | | `403` (reads) | not the owner (inbox, owner followers/following) |
|
|---|
| 194 | | `403 not_public` | Like/Announce of a non-public local post |
|
|---|
| 195 | | `401` | dead or missing bearer: re-authenticate |
|
|---|
| 196 | | `502 cannot_resolve_inReplyTo` / `direct_failed` | a delivery target could not be resolved |
|
|---|
| 197 |
|
|---|
| 198 | ## 8. Notes and limits
|
|---|
| 199 |
|
|---|
| 200 | - The enriched followers/following display is a Klonkt convenience. A generic AP
|
|---|
| 201 | server returns bare ids; the client falls back to a derived handle.
|
|---|
| 202 | - This is ActivityPub C2S. There is no Mastodon-compatible client API.
|
|---|
| 203 | - See `FEDERATION.md` for the server-to-server surface (activities, signatures,
|
|---|
| 204 | extension terms, supported FEPs).
|
|---|