source: Klonkt/docs/shaer-c2s-api.md@ f889429

main
Last change on this file since f889429 was f889429, checked in by roboburr <roboburr@…>, 5 weeks ago

Stilte kost nu niets: 304 als er niets veranderd is (shaer-n05)

Barts punt, en het legt een echte fout in mijn ontwerp bloot. De wachtende
inbox-lezing stuurde bij elke afloop van de wachttijd de VOLLEDIGE collectie
terug -- elke 25 seconden een tijdlijn over de mobiele verbinding om te zeggen
dat er niets gebeurd is. Dat is een slecht ruilmiddel voor het uitsparen van een
rondje.

Nu: 304 zonder body als de merksteen niet bewoog, de volle collectie als hij dat
wel deed. Daarmee is dit beter dan allebei de ontwerpen die op tafel lagen --
een apart seintje-endpoint kost bij nieuws twee rondjes, dit een.

DE '0'-UITZONDERING IS GEEN FRANJE. Ontbreekt ap_feed_state, dan geeft feedCursor
altijd '0' en zou een client hier eeuwig 304 krijgen en nooit meer inhoud zien.
Bij een lege merksteen gaat de collectie dus gewoon mee: trager, nooit fout.

Aan de clientkant moest de 304-tak VOOR de foutcontrole, anders telt stilte als
een storing en duwt hij de wachtlus in zijn oplopende pauze. En caps worden bij
een 304 NIET overschreven: een leeg antwoord draagt geen rechten, en die
klakkeloos overnemen zou de gated rechten terugzetten naar "alles mag". Vandaar
dat het antwoord nu expliciet changed draagt in plaats van dat de beller het uit
de cursor moet afleiden.

Suite 544/544.

  • Property mode set to 100644
File size: 11.5 KB
Line 
1# Shaer to Klonkt: the C2S API
2
3This documents the contract the Shaer apps (iOS, Android) use to drive a Klonkt
4account. 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:
6Mastodon apps use their own API and are not served here.
7
8Everything is discovered, never hardcoded: a client resolves a handle to an
9actor document and reads the endpoints from it, so the same client works against
10any Klonkt instance (and degrades gracefully against other AP servers).
11
12Base URL below is the instance origin, e.g. `https://klonkt.example`. All AP
13requests send and accept `application/activity+json`.
14
15## 1. Discovery
16
171. **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.
212. **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
423. **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
48If the actor lacks the OAuth endpoints, the server does not support posting from
49apps; the client should degrade to read-only.
50
51## 2. Authentication (OAuth 2.0, public client + PKCE)
52
53Public clients only. No client secret. PKCE with S256 is required.
54
551. **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).
592. **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.
663. **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
70The bearer is scoped to one user and one site. Send it as
71`Authorization: Bearer <token>` on every authenticated call. Tokens are stored
72hashed 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
85The inbox read can hold the answer until there is something new, so a client does
86not have to poll. It is the **same call and the same response** — no separate
87endpoint and no separate "there is news" shape, because a second shape is a
88second description of a post that can drift from the first.
89
90```
91GET /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
98Two 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
106this 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
108round trip.
109
110A server that has not run the feed-state migration yet cannot tell change from
111stillness, and answers `200` with the collection every time rather than `304`
112forever. Slower, never wrong.
113
114The cursor moves for **all four** sources this read merges: the timeline,
115messages, replies on your own posts, and your own sent notes.
116
117Limits 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
126This works while your app is in the foreground, and only there. iOS freezes
127network activity the moment an app is backgrounded, and a held-open connection
128does not survive it.
129
130**There is no background channel to fall back on for a native app.** Klonkt's
131web push uses VAPID, which a self-hosted instance can generate and send entirely
132on its own — but only to a browser or an installed PWA. A native app receives
133push through APNs (or FCM), signed with the *application's* key, which a
134self-hosted Klonkt does not have and should not have: whoever holds it can push
135to every user of that app. Closing that gap needs a relay run by the app's
136publisher, and that is a decision about money and control, not a protocol
137detail.
138
139So for a self-hosted setup this is not a stopgap until push arrives. For now it
140is 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
145owner's bearer they return the real entries. By default these are bare id
146strings; send `Prefer: return=representation` (FEP-9876) to get them enriched as
147AS2 actor references with display, so a client shows names and avatars instead of
148bare 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
163Display priority for a contact is `name`, then `preferredUsername`, then a handle
164derived from the id. Entries may also arrive as bare id strings (other servers,
165or entries with no cached display); a client handles both shapes.
166
167## 4. Writes: `POST /ap/users/:slug/outbox` (bearer)
168
169Post an Activity, or a bare object which the server wraps in a `Create` per the
170AP spec. Supported: `Create` (Note), `Like`, `Announce`, `Follow`, and `Undo` of
171`Follow` / `Like` / `Announce`. `Delete` and `Update` over C2S are not yet
172implemented.
173
174A `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
184Visibility comes from the note's `to` / `cc`, the Mastodon model. There is no
185separate 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
195the actor's followers collection URL. No addressing at all is treated as public
196(legacy).
197
198### 4.2 Direct notes (private mentions)
199
200A note addressed only to actor URIs (no Public, no followers) is a direct
201message, not a post. Klonkt delivers it S2S to exactly those inboxes: no
202followers fan-out, empty `cc`, so it can never be boosted or appear in a
203timeline. A guardian on any AP server (even plain Mastodon) receives it as a
204private mention. Address the recipients as `Mention` tags so their servers
205notify them. A direct note with no resolvable recipient is refused
206(`400 no_recipients`).
207
208### 4.3 Attachments
209
210Put 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
216A ward's call for help is a direct note carrying `"shaer:helpRequest": true`,
217addressed to all its guardians, optionally with a capture as an `attachment`, the
218subject as an FEP-e232 object link (never a `Mention`, which would add the
219subject to the conversation), and a short text quote. Guardian-side clients may
220render it as an alert; other servers read a normal private mention. See FEP-633c
2215.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.
231Returns `201 { "url": "/media/reply-media/...", "mediaType": "image/png", "name": "..." }`.
232Use `url` in an `attachment` on the next note.
233
234## 6. The `shaer:` vocabulary (FEP-633c)
235
236Namespace `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
245The 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).
Note: See TracBrowser for help on using the repository browser.