source: Klonkt/docs/cirkels-v1-spec.md@ 92cd272

main
Last change on this file since 92cd272 was b300682, checked in by roboburr <roboburr@…>, 3 months ago

Circles v1 — foundation: migration + signed own publication

Steps 1+2 of the Circles federation (3rd tenancy mode alongside solo/hub):

  • DB: circle_links / remote_actors / remote_posts (idempotent in initializeDatabase)
  • SettingsService: tenancy now accepts 'circle'
  • CircleFederation.js: per-instance Ed25519 keypair (app_settings) + actor/outbox builders + sign/verify (SPKI-DER pubkey, signature over raw body)
  • routes/federation.js: GET /.klonkt/actor.json + signed /.klonkt/outbox.json, mounted in server.js before resolveSite
  • docs/cirkels-v1-spec.md: full v1 spec

Still to do: CircleService.sync (pull+verify), Admin UI, /cirkel feed, hardening.
Ed25519 sign/verify round-trip verified in isolation; syntax clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@…>

  • Property mode set to 100644
File size: 12.2 KB
Line 
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
7Status: 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
141. **Geen centrale content.** Elke instance hbost z'n eigen posts. Een cirkel is
15 enkel een *verzameling verbindingen*, geen opslag.
162. **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`.
193. **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.
224. **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`).
255. **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".
306. **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
47site), met daarbovenop de cirkel-routes (§5).
48
49```js
50// SettingsService.js
51export 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
61Hergebruik 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)
66CREATE 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)
81CREATE 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)
92CREATE 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
113Elke Klonkt-instance publiceert **twee statische, publieke JSON-documenten** op vaste
114paden. Geen auth, geen inbox — alleen *lezen*.
115
116### 4a. Actor — `GET /.klonkt/actor.json`
117
118ActivityStreams `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
142AS `OrderedCollection` van recente `Create`→`Note`/`Audio`-objecten (alleen publieke
143posts). 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()`
201Periodiek (cron, bv. elke 15 min) + handmatige "ververs"-knop:
202```
203voor 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```
211Robuust/tolerant: time-outs, max body-size, alleen `https`, alleen verwachte velden,
212onbekende 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)
2711. Migratie (3 tabellen) + `getTenancy` → `circle` + Beheer-toggle.
2722. Eigen publicatie: keypair-bootstrap + `/.klonkt/actor.json` + `/.klonkt/outbox.json`
273 + `Klonkt-Signature`. (Testbaar: `curl` + signatuur-verify-scriptje.)
2743. `CircleService.sync()` (fetch+verify+cache) + cron + "ververs"-knop.
2754. Beheer → Cirkel (toevoegen/lijst/status).
2765. `/cirkel`-feed (statische kaarten) + home-strook.
2776. Sanitatie/security-hardening + adversariële review (signatuur-spoof, cross-origin
278 object-id, XSS-import, key-rotation).
Note: See TracBrowser for help on using the repository browser.