| 1 | # Klonkt — Cirkels (v1-spec)
|
|---|
| 2 |
|
|---|
| 3 | > Derde tenancy-optie naast **solo** en **hub**. Laat zelf-gehoste **solo-instances**
|
|---|
| 4 | > elkaars publieke content tonen, **decentraal** en **asymmetrisch** — zónder centraal
|
|---|
| 5 | > punt, zónder centrale moderatie, zónder dat iemand (ook Robin niet) de cirkel bezit.
|
|---|
| 6 |
|
|---|
| 7 | Status: ontwerp. Bouwt voort op de bestaande tenancy-laag (`SettingsService`,
|
|---|
| 8 | `app_settings`, `resolveSite`/`site.js`, `sites`-schema).
|
|---|
| 9 |
|
|---|
| 10 | ---
|
|---|
| 11 |
|
|---|
| 12 | ## 1. Principes (waarom dit veilig is voor het self-host-model)
|
|---|
| 13 |
|
|---|
| 14 | 1. **Geen centrale content.** Elke instance hbost z'n eigen posts. Een cirkel is
|
|---|
| 15 | enkel een *verzameling verbindingen*, geen opslag.
|
|---|
| 16 | 2. **Asymmetrisch (volgen, geen vriendschap).** A toont B omdat A dat kiest — los van
|
|---|
| 17 | of B A toont. "4 bandleden droppen de 5e" kan; de 5e houdt de 4 gewoon in z'n eigen
|
|---|
| 18 | cirkel. Dit is de Mastodon/Twitter-`Follow`-semantiek, niet de Facebook-`Friend`.
|
|---|
| 19 | 3. **Per-instance beheer + zelf-policing.** Elke admin beheert *zijn eigen* cirkel
|
|---|
| 20 | (Klonkt-URL's toevoegen/weghalen). Bevalt een bron niet → eruit. Geen centrale
|
|---|
| 21 | autoriteit, dus geen moderatie-/aansprakelijkheidslast bij de leverancier.
|
|---|
| 22 | 4. **Publiek = publiek.** Een cirkel toont alleen reeds-publieke posts (zoals RSS /
|
|---|
| 23 | embedding van het open web). Een bron kan zich wel **afmelden** voor surfacing
|
|---|
| 24 | (zie §6, `allow_circle`).
|
|---|
| 25 | 5. **Standaard-compatibel datamodel, simpel transport.** De content krijgt de **vorm**
|
|---|
| 26 | van **ActivityStreams 2.0 / schema.org**, maar v1 **transporteert** via een
|
|---|
| 27 | simpele **getekende pull** (geen volledige ActivityPub-server). De echte AP-brug
|
|---|
| 28 | (inbox/outbox, WebFinger, HTTP-signatures) komt in v2 als fediverse-koppeling.
|
|---|
| 29 | → Eert Bart's "gebruik de standaarden" én Robin's "licht & tolerant eerst".
|
|---|
| 30 | 6. **v1 = statisch.** Gecachte publieke kaarten. **Cross-instance comments/interactie
|
|---|
| 31 | blijft GEPARKEERD** — dáár komt de moderatie/abuse-ellende terug.
|
|---|
| 32 |
|
|---|
| 33 | ---
|
|---|
| 34 |
|
|---|
| 35 | ## 2. Tenancy-model
|
|---|
| 36 |
|
|---|
| 37 | `app_settings.tenancy` krijgt een derde waarde:
|
|---|
| 38 |
|
|---|
| 39 | | Modus | Structuur |
|
|---|
| 40 | |---|---|
|
|---|
| 41 | | `solo` | 1 site, geen federatie |
|
|---|
| 42 | | `hub` | 1 instance, N sites, centraal beheerd |
|
|---|
| 43 | | `circle` | 1 solo-site **+** een cirkel-feed van remote Klonkt-instances |
|
|---|
| 44 |
|
|---|
| 45 | `circle` = functioneel "solo + cirkel-feature aan". `getTenancy()` wordt uitgebreid;
|
|---|
| 46 | `resolveSite` gedraagt zich voor de lokale routes identiek aan `solo` (pin de primaire
|
|---|
| 47 | site), met daarbovenop de cirkel-routes (§5).
|
|---|
| 48 |
|
|---|
| 49 | ```js
|
|---|
| 50 | // SettingsService.js
|
|---|
| 51 | export function getTenancy() {
|
|---|
| 52 | const v = getSetting('tenancy', 'solo');
|
|---|
| 53 | return v === 'hub' ? 'hub' : v === 'circle' ? 'circle' : 'solo';
|
|---|
| 54 | }
|
|---|
| 55 | ```
|
|---|
| 56 |
|
|---|
| 57 | ---
|
|---|
| 58 |
|
|---|
| 59 | ## 3. Datamodel (nieuwe migratie)
|
|---|
| 60 |
|
|---|
| 61 | Hergebruik bestaand: `sites.origin_server` (al `'local'` default), `sites.is_public`,
|
|---|
| 62 | `sites.closed_circle_mode`. Nieuw:
|
|---|
| 63 |
|
|---|
| 64 | ```sql
|
|---|
| 65 | -- Wie de lokale site volgt (asymmetrisch, lokaal beheerd)
|
|---|
| 66 | CREATE TABLE IF NOT EXISTS circle_links (
|
|---|
| 67 | id TEXT PRIMARY KEY,
|
|---|
| 68 | local_site_id TEXT NOT NULL, -- onze site die deze bron toont
|
|---|
| 69 | remote_url TEXT NOT NULL, -- canonieke instance-URL (https://...)
|
|---|
| 70 | remote_actor_id TEXT, -- ingevuld na eerste fetch
|
|---|
| 71 | label TEXT, -- admin-notitie / weergavenaam
|
|---|
| 72 | status TEXT DEFAULT 'active', -- active | paused | error
|
|---|
| 73 | added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|---|
| 74 | last_synced DATETIME,
|
|---|
| 75 | last_error TEXT,
|
|---|
| 76 | UNIQUE(local_site_id, remote_url),
|
|---|
| 77 | FOREIGN KEY (local_site_id) REFERENCES sites(id)
|
|---|
| 78 | );
|
|---|
| 79 |
|
|---|
| 80 | -- Gecachete remote actor (incl. publieke sleutel voor verificatie)
|
|---|
| 81 | CREATE TABLE IF NOT EXISTS remote_actors (
|
|---|
| 82 | id TEXT PRIMARY KEY, -- = actor.id (de canonieke URL)
|
|---|
| 83 | url TEXT UNIQUE NOT NULL,
|
|---|
| 84 | name TEXT,
|
|---|
| 85 | summary TEXT,
|
|---|
| 86 | avatar TEXT,
|
|---|
| 87 | public_key TEXT NOT NULL, -- Ed25519, base64
|
|---|
| 88 | fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|---|
| 89 | );
|
|---|
| 90 |
|
|---|
| 91 | -- Gecachete remote posts (statische snapshot, AS-object)
|
|---|
| 92 | CREATE TABLE IF NOT EXISTS remote_posts (
|
|---|
| 93 | id TEXT PRIMARY KEY, -- = object.id (remote canonieke URL)
|
|---|
| 94 | actor_id TEXT NOT NULL,
|
|---|
| 95 | published DATETIME,
|
|---|
| 96 | title TEXT,
|
|---|
| 97 | summary TEXT, -- platte tekst, gesanitized
|
|---|
| 98 | url TEXT, -- link terug naar de bron
|
|---|
| 99 | media_json TEXT, -- genormaliseerde media-refs (JSON)
|
|---|
| 100 | raw_json TEXT, -- origineel AS-object (audit)
|
|---|
| 101 | fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|---|
| 102 | FOREIGN KEY (actor_id) REFERENCES remote_actors(id)
|
|---|
| 103 | );
|
|---|
| 104 | ```
|
|---|
| 105 |
|
|---|
| 106 | > `circle_links` is bewust **per `local_site_id`** zodat het model meteen klopt als een
|
|---|
| 107 | > hub later óók cirkels wil (elke site z'n eigen cirkel). Voor solo is er één site.
|
|---|
| 108 |
|
|---|
| 109 | ---
|
|---|
| 110 |
|
|---|
| 111 | ## 4. Het protocol (v1: getekende pull, AS-vormig)
|
|---|
| 112 |
|
|---|
| 113 | Elke Klonkt-instance publiceert **twee statische, publieke JSON-documenten** op vaste
|
|---|
| 114 | paden. Geen auth, geen inbox — alleen *lezen*.
|
|---|
| 115 |
|
|---|
| 116 | ### 4a. Actor — `GET /.klonkt/actor.json`
|
|---|
| 117 |
|
|---|
| 118 | ActivityStreams `Person`/`Service`-actor met de **publieke sleutel** (Ed25519):
|
|---|
| 119 |
|
|---|
| 120 | ```json
|
|---|
| 121 | {
|
|---|
| 122 | "@context": ["https://www.w3.org/ns/activitystreams", "https://schema.org/"],
|
|---|
| 123 | "type": "Person",
|
|---|
| 124 | "id": "https://joostklein.klonkt.com/.klonkt/actor.json",
|
|---|
| 125 | "name": "Joost Klein",
|
|---|
| 126 | "summary": "Frisse gozer met platen.",
|
|---|
| 127 | "url": "https://joostklein.klonkt.com/",
|
|---|
| 128 | "icon": { "type": "Image", "url": "https://joostklein.klonkt.com/img/avatar.webp" },
|
|---|
| 129 | "outbox": "https://joostklein.klonkt.com/.klonkt/outbox.json",
|
|---|
| 130 | "publicKey": {
|
|---|
| 131 | "id": "https://joostklein.klonkt.com/.klonkt/actor.json#key",
|
|---|
| 132 | "owner": "https://joostklein.klonkt.com/.klonkt/actor.json",
|
|---|
| 133 | "algorithm": "ed25519",
|
|---|
| 134 | "publicKeyBase64": "M0r3...base64..."
|
|---|
| 135 | },
|
|---|
| 136 | "klonkt": { "version": 1, "allowCircle": true }
|
|---|
| 137 | }
|
|---|
| 138 | ```
|
|---|
| 139 |
|
|---|
| 140 | ### 4b. Outbox — `GET /.klonkt/outbox.json`
|
|---|
| 141 |
|
|---|
| 142 | AS `OrderedCollection` van recente `Create`→`Note`/`Audio`-objecten (alleen publieke
|
|---|
| 143 | posts). Statisch gegenereerd bij elke post-mutatie (cache-bestand of route).
|
|---|
| 144 |
|
|---|
| 145 | ```json
|
|---|
| 146 | {
|
|---|
| 147 | "@context": "https://www.w3.org/ns/activitystreams",
|
|---|
| 148 | "type": "OrderedCollection",
|
|---|
| 149 | "id": "https://joostklein.klonkt.com/.klonkt/outbox.json",
|
|---|
| 150 | "totalItems": 2,
|
|---|
| 151 | "orderedItems": [
|
|---|
| 152 | {
|
|---|
| 153 | "type": "Create",
|
|---|
| 154 | "id": "https://joostklein.klonkt.com/posts/zomer-2026#create",
|
|---|
| 155 | "published": "2026-06-14T18:00:00Z",
|
|---|
| 156 | "actor": "https://joostklein.klonkt.com/.klonkt/actor.json",
|
|---|
| 157 | "object": {
|
|---|
| 158 | "type": "Article",
|
|---|
| 159 | "id": "https://joostklein.klonkt.com/posts/zomer-2026",
|
|---|
| 160 | "name": "Zomerplaat",
|
|---|
| 161 | "summary": "Nieuwe single uit.",
|
|---|
| 162 | "url": "https://joostklein.klonkt.com/posts/zomer-2026",
|
|---|
| 163 | "published": "2026-06-14T18:00:00Z",
|
|---|
| 164 | "attachment": [
|
|---|
| 165 | { "type": "Audio", "url": "https://joostklein.klonkt.com/audio/stream/zomer.mp3",
|
|---|
| 166 | "name": "Zomerplaat", "duration": "PT3M21S" }
|
|---|
| 167 | ]
|
|---|
| 168 | }
|
|---|
| 169 | }
|
|---|
| 170 | ]
|
|---|
| 171 | }
|
|---|
| 172 | ```
|
|---|
| 173 |
|
|---|
| 174 | ### 4c. Tekenen + verifiëren (anti-spoofing)
|
|---|
| 175 |
|
|---|
| 176 | - De instance tekent het **outbox-document** met z'n Ed25519-private sleutel.
|
|---|
| 177 | v1-keuze: **detached signature in een HTTP-header** bij de outbox-respons:
|
|---|
| 178 | `Klonkt-Signature: ed25519=<base64(sig over raw body)>`.
|
|---|
| 179 | (Eenvoudiger dan HTTP Message Signatures; v2 kan naar de RFC-9421-variant.)
|
|---|
| 180 | - Consument: fetch `actor.json` → pak `publicKeyBase64` → fetch `outbox.json` →
|
|---|
| 181 | verifieer `Klonkt-Signature` over de **ruwe body** met die sleutel. Mismatch → drop +
|
|---|
| 182 | `circle_links.status='error'`, `last_error` gevuld.
|
|---|
| 183 | - **TOFU** (trust-on-first-use): de eerste keer wordt de pubkey gecachet in
|
|---|
| 184 | `remote_actors`. Verandert 'ie later → waarschuw de admin (key-rotation = expliciete
|
|---|
| 185 | her-bevestiging), zoals SSH.
|
|---|
| 186 |
|
|---|
| 187 | > Sleutelopslag lokaal: genereer per-instance één Ed25519-keypair bij eerste start,
|
|---|
| 188 | > bewaar in `app_settings` (`circle_privkey` / `circle_pubkey`, base64). Privé-sleutel
|
|---|
| 189 | > nooit serveren; alleen de publieke in `actor.json`.
|
|---|
| 190 |
|
|---|
| 191 | ---
|
|---|
| 192 |
|
|---|
| 193 | ## 5. Server-flow & routes
|
|---|
| 194 |
|
|---|
| 195 | ### 5a. Publiceren (onze kant)
|
|---|
| 196 | - Genereer/ververs `/.klonkt/actor.json` + `/.klonkt/outbox.json` (alleen `is_public`
|
|---|
| 197 | posts; respecteer `allow_circle`). Trigger: post-create/update/delete + nightly.
|
|---|
| 198 | - Statisch cachen (bestand of in-memory) + de `Klonkt-Signature`-header zetten.
|
|---|
| 199 |
|
|---|
| 200 | ### 5b. Pullen (cirkel verversen) — `CircleService.sync()`
|
|---|
| 201 | Periodiek (cron, bv. elke 15 min) + handmatige "ververs"-knop:
|
|---|
| 202 | ```
|
|---|
| 203 | voor elke circle_links (status=active):
|
|---|
| 204 | fetch remote_url + '/.klonkt/actor.json' (volg redirect naar canoniek)
|
|---|
| 205 | upsert remote_actors (pubkey TOFU-check)
|
|---|
| 206 | fetch actor.outbox (met Klonkt-Signature)
|
|---|
| 207 | verifieer signature met pubkey -> faal? status=error, continue
|
|---|
| 208 | voor elk Create/object: sanitize + upsert remote_posts
|
|---|
| 209 | circle_links.last_synced = now, status=active
|
|---|
| 210 | ```
|
|---|
| 211 | Robuust/tolerant: time-outs, max body-size, alleen `https`, alleen verwachte velden,
|
|---|
| 212 | onbekende velden negeren (Postel's law — Bart's "tolerant & robuust").
|
|---|
| 213 |
|
|---|
| 214 | ### 5c. Tonen — nieuwe route `/cirkel` (+ feed-blok op de home)
|
|---|
| 215 | - `routes/circle.js` (mount alleen als `tenancy==='circle'`): toont een
|
|---|
| 216 | tijd-gesorteerde **statische kaarten-feed** van `remote_posts` (titel, samenvatting,
|
|---|
| 217 | bron-avatar/naam, "via <instance>"-badge, link terug naar de bron). Geen interactie.
|
|---|
| 218 | - Optioneel een compacte "Uit je cirkel"-strook op de solo-home.
|
|---|
| 219 | - Media: v1 toont een **link/representatie** terug naar de bron (geen herhosting). De
|
|---|
| 220 | `media_json` mag een resting-kaart renderen die naar de bron-URL linkt (embeddable
|
|---|
| 221 | komt in v2; nu geen cross-host streaming/CSP-gedoe).
|
|---|
| 222 |
|
|---|
| 223 | ### 5d. Beheer-UX — Beheer → **Cirkel**
|
|---|
| 224 | - Lijst van `circle_links` met status (✓ active / ⏸ paused / ⚠ error + reden).
|
|---|
| 225 | - Input "Voeg een Klonkt-site toe" (plak URL) → validatie (fetch actor, toon
|
|---|
| 226 | naam/avatar ter bevestiging) → opslaan.
|
|---|
| 227 | - Per bron: pauzeren / verwijderen / nu-verversen.
|
|---|
| 228 | - Toggle **"Mijn site mag in cirkels van anderen verschijnen"** → `sites.is_public`
|
|---|
| 229 | i.c.m. een nieuwe `allow_circle`-flag (default aan) → stuurt `actor.allowCircle`.
|
|---|
| 230 |
|
|---|
| 231 | ---
|
|---|
| 232 |
|
|---|
| 233 | ## 6. Privacy, veiligheid, edge-cases
|
|---|
| 234 |
|
|---|
| 235 | - **Opt-out van surfacing**: `allow_circle=0` → onze `actor.json`/`outbox.json` geven
|
|---|
| 236 | `allowCircle:false` + lege outbox. Een nette consument respecteert dat (zoals
|
|---|
| 237 | robots). Hard afdwingen kan niet (publieke bytes) — eerlijk benoemen, zoals het web.
|
|---|
| 238 | - **Remote HTML-sanitatie**: remote `summary`/titels strikt strippen naar platte tekst
|
|---|
| 239 | vóór opslag/rendering (geen remote HTML/CSS in onze DOM → geen XSS-import; vgl. de
|
|---|
| 240 | `mailapp`-keuze om geen vreemde HTML te injecteren).
|
|---|
| 241 | - **Alleen https**, redirect-limiet, body-size-limiet, fetch-timeout, rate-limit per
|
|---|
| 242 | bron. Verifieer dat `object.id`/`url` op **hetzelfde origin** als de actor staan
|
|---|
| 243 | (anti-impersonatie: bron mag geen posts "namens" een andere instance claimen).
|
|---|
| 244 | - **Key-rotation** = expliciete admin-herbevestiging (TOFU).
|
|---|
| 245 | - **Verwijderingen**: outbox is de bron van waarheid; remote_posts die niet meer in de
|
|---|
| 246 | outbox staan → opruimen (tombstone of hard delete) bij sync.
|
|---|
| 247 | - **Geen wederkerigheid afdwingen** — bewust. Discovery = handmatig URL toevoegen.
|
|---|
| 248 |
|
|---|
| 249 | ---
|
|---|
| 250 |
|
|---|
| 251 | ## 7. Wat v1 NIET doet (expliciet geparkeerd)
|
|---|
| 252 | - Cross-instance **comments/likes/interactie** (moderatie/abuse-risico).
|
|---|
| 253 | - **Herhosting** van remote audio/media (v1 linkt terug; embeddable = v2).
|
|---|
| 254 | - Volledige **ActivityPub** (inbox, bezorging, WebFinger, HTTP-signatures) — v2-brug.
|
|---|
| 255 | - Centrale **discovery/index**.
|
|---|
| 256 |
|
|---|
| 257 | ---
|
|---|
| 258 |
|
|---|
| 259 | ## 8. Toekomst (v2+, voorbereid maar niet gebouwd)
|
|---|
| 260 | - **ActivityPub-brug**: omdat de datavorm al AS 2.0 is, is de stap naar een echte
|
|---|
| 261 | `inbox`/`outbox` + RFC-9421 HTTP Message Signatures + WebFinger incrementeel — dan
|
|---|
| 262 | praat Klonkt ook met Mastodon/fediverse. Premium/optioneel.
|
|---|
| 263 | - **Embeddable content**: cross-host players (hergebruik het bestaande embed-player +
|
|---|
| 264 | PlaybackRegistry-patroon), met CSP-uitbreiding per vertrouwde bron.
|
|---|
| 265 | - **Hub × cirkels**: een hub-site kan zelf een cirkel hebben (model is er al klaar voor
|
|---|
| 266 | via `circle_links.local_site_id`).
|
|---|
| 267 |
|
|---|
| 268 | ---
|
|---|
| 269 |
|
|---|
| 270 | ## 9. Implementatie-volgorde (incrementeel, elk los testbaar)
|
|---|
| 271 | 1. Migratie (3 tabellen) + `getTenancy` → `circle` + Beheer-toggle.
|
|---|
| 272 | 2. Eigen publicatie: keypair-bootstrap + `/.klonkt/actor.json` + `/.klonkt/outbox.json`
|
|---|
| 273 | + `Klonkt-Signature`. (Testbaar: `curl` + signatuur-verify-scriptje.)
|
|---|
| 274 | 3. `CircleService.sync()` (fetch+verify+cache) + cron + "ververs"-knop.
|
|---|
| 275 | 4. Beheer → Cirkel (toevoegen/lijst/status).
|
|---|
| 276 | 5. `/cirkel`-feed (statische kaarten) + home-strook.
|
|---|
| 277 | 6. Sanitatie/security-hardening + adversariële review (signatuur-spoof, cross-origin
|
|---|
| 278 | object-id, XSS-import, key-rotation).
|
|---|