source: Klonkt/src/config/database.js@ ffb371c

main
Last change on this file since ffb371c was ffb371c, checked in by roboburr <roboburr@โ€ฆ>, 5 weeks ago

Antwoorden is ook iets: een eigen poort (shaer-r4c)

Bart, 8-8: "Ook meedoen aan een gesprek valt onder een gate, maak die en maak die
zichtbaar."

HIER STOND EEN AANNAME, EN DIE WAS VAN MIJ. compose liet een antwoord bewust
door, met als reden "een antwoord is meedoen aan een gesprek, geen eigen podium".
Dat is een ontwerpkeuze die ik als vanzelfsprekend had opgeschreven, en Bart
draait hem terug: meedoen aan een gesprek is ook iets waar guardians over gaan.

EEN EIGEN POORT, NIET ONDER COMPOSE. Los in beide richtingen, en daar staan
toetsen op: je kunt willen dat een kind meepraat zonder eigen podium, en ook
precies andersom. Zou compose antwoorden meesluiten, dan is de nieuwe rij in het
paneel een knop die niets doet.

DE DEUR NAAST DE POORT. De innamepoort in ingestOutboxActivity dekt alleen C2S --
Shaer. routes/posts.js roept deliverReply op drie plekken RECHTSTREEKS aan, dus
de eigen webinterface van Klonkt loopt er nooit langs. Een poort die alleen in de
app staat is geen poort. De echte controle staat nu in deliverReply zelf, het
knooppunt dat beide paden delen; de outbox houdt zijn eigen check zodat de app
een nette 403 gated_replies krijgt in plaats van een stille null.

De boei komt daar niet langs en dat is geen toeval: een hulpvraag is altijd
direct en loopt via deliverDirectNote. Er is dus geen uitzondering nodig om hem
open te houden -- maar er staat wel een toets op, want dit is het gevaarlijkste
dat deze poort kan doen: een kind dat om hulp vraagt in het draadje waar het
misgaat.

Een direct antwoord wordt door allebei de poorten gedekt (messages en replies).
Een prive-antwoord is allebei, en dan mag allebei hem tegenhouden.

Paneel en shaer:capabilities krijgen hem gratis uit de catalogus. Labels in
nl/en/de. Zes toetsen, drie mutaties gecontroleerd. Suite 646/646.

  • Property mode set to 100644
File size: 50.5 KB
Lineย 
1import Database from 'better-sqlite3';
2import path from 'path';
3import { fileURLToPath } from 'url';
4import fs from 'fs';
5
6const __dirname = path.dirname(fileURLToPath(import.meta.url));
7const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '../../storage/database.sqlite');
8
9// Ensure storage directory exists
10const storageDir = path.dirname(dbPath);
11if (!fs.existsSync(storageDir)) {
12 fs.mkdirSync(storageDir, { recursive: true });
13}
14
15// Initialize database
16const db = new Database(dbPath);
17db.pragma('journal_mode = WAL');
18db.pragma('foreign_keys = ON');
19// With WAL + several concurrent writers (request handlers, the delivery worker, the
20// background thread-crawler) a short write-lock should retry rather than throw SQLITE_BUSY.
21db.pragma('busy_timeout = 5000'); // wait up to 5s for a lock instead of failing immediately
22db.pragma('synchronous = NORMAL'); // safe with WAL (no torn writes); fewer fsyncs = faster writes
23
24export function initializeDatabase() {
25 const tableExists = db.prepare(`
26 SELECT name FROM sqlite_master WHERE type='table' AND name='users'
27 `).get();
28
29 if (!tableExists) {
30 console.log('๐Ÿ”ง Initializing database schema...');
31 const schemaPath = path.join(__dirname, '..', 'db', 'migrations', '001-init.sql');
32 const schema = fs.readFileSync(schemaPath, 'utf-8');
33 db.exec(schema);
34 console.log('โœ… Database initialized with v9-soul schema');
35 }
36
37 // Additive column migrations โ€” safe to run every boot.
38 // SQLite throws if the column already exists; we swallow that.
39 ensureColumn('sites', 'enable_audio_player', 'INTEGER DEFAULT 1');
40 // (Verwijderd 31-7-2026: sites.guardian_only en ap_guardian_invites hoorden
41 // bij de guardian-lite accounts. Bestaande installaties houden kolom en tabel
42 // ongebruikt; nieuwe krijgen ze niet meer.)
43 // FEP-633c ยง5.3: follows targeting a ward are held pending until its
44 // guardians approve (Guardian 2). Gating applies only to ward-actors.
45 db.exec(`CREATE TABLE IF NOT EXISTS ap_pending_follows (
46 id TEXT PRIMARY KEY,
47 ward_slug TEXT NOT NULL,
48 follower_uri TEXT NOT NULL,
49 follower_inbox TEXT,
50 follower_shared_inbox TEXT,
51 follower_name TEXT,
52 follower_handle TEXT,
53 follower_icon TEXT,
54 activity_json TEXT,
55 quorum TEXT DEFAULT 'any',
56 status TEXT DEFAULT 'pending',
57 created_at TEXT DEFAULT CURRENT_TIMESTAMP
58 )`);
59 db.exec(`CREATE TABLE IF NOT EXISTS ap_pending_follow_approvals (
60 follow_id TEXT NOT NULL,
61 guardian_uri TEXT NOT NULL,
62 decision TEXT NOT NULL,
63 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
64 PRIMARY KEY (follow_id, guardian_uri)
65 )`);
66 // FEP-633c ยง5.3, the OTHER direction (shaer-p729): a ward's own follow is
67 // held until its guardians approve. Deliberately not ap_pending_follows โ€”
68 // that table is keyed with the ward as the TARGET ("who wants to follow me"),
69 // and adding a direction column would make every existing query ambiguous.
70 db.exec(`CREATE TABLE IF NOT EXISTS ap_pending_outgoing_follows (
71 id TEXT PRIMARY KEY,
72 ward_slug TEXT NOT NULL,
73 target_uri TEXT NOT NULL,
74 target_inbox TEXT,
75 target_name TEXT,
76 target_handle TEXT,
77 target_icon TEXT,
78 quorum TEXT DEFAULT 'any',
79 status TEXT DEFAULT 'pending',
80 created_at TEXT DEFAULT CURRENT_TIMESTAMP
81 )`);
82 db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ap_outgoing_follows_target
83 ON ap_pending_outgoing_follows(ward_slug, target_uri)`);
84 db.exec(`CREATE TABLE IF NOT EXISTS ap_outgoing_follow_approvals (
85 follow_id TEXT NOT NULL,
86 guardian_uri TEXT NOT NULL,
87 decision TEXT NOT NULL,
88 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
89 PRIMARY KEY (follow_id, guardian_uri)
90 )`);
91 // Cross-instance follow-approval (modelled on the guardian offer): the
92 // guardian-side COPY of a gated follow on a REMOTE ward, forwarded here by
93 // the ward's server as an Offer(Follow). The decision is sent back to the
94 // ward's inbox. (Local wards use ap_pending_follows directly.)
95 db.exec(`CREATE TABLE IF NOT EXISTS ap_follow_reviews (
96 id TEXT NOT NULL,
97 guardian_slug TEXT NOT NULL,
98 ward_uri TEXT NOT NULL,
99 ward_inbox TEXT,
100 follower_uri TEXT NOT NULL,
101 follower_handle TEXT,
102 follower_icon TEXT,
103 follow_json TEXT,
104 status TEXT DEFAULT 'pending',
105 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
106 PRIMARY KEY (guardian_slug, id)
107 )`);
108 // Guardianship Fase 2 (shaer-jdb): een doorgestuurde follow-goedkeuring draagt
109 // een RICHTING. Bij een inkomende is de follower iemand anders en de ward het
110 // doel; bij een uitgaande is de ward zelf de follower en staat het doel in het
111 // Follow-object. Zonder deze twee kolommen werd een uitgaande opgeslagen als
112 // "deze ward wil deze ward volgen" en viel het doel weg -- dan valt er niets
113 // zinnigs te tonen, hoe je de wachtrij ook vult.
114 ensureColumn('ap_follow_reviews', 'direction', "TEXT DEFAULT 'incoming'");
115 ensureColumn('ap_follow_reviews', 'target_uri', 'TEXT');
116 ensureColumn('ap_follow_reviews', 'target_handle', 'TEXT');
117 ensureColumn('sites', 'profile_photo', 'TEXT');
118 ensureColumn('audio_tracks', 'cover_url', 'TEXT');
119 ensureColumn('audio_tracks', 'album', 'TEXT');
120 ensureColumn('users', 'reset_token', 'TEXT');
121 ensureColumn('users', 'reset_token_expires', 'DATETIME');
122 // Google OAuth: link a Google account to a user (login via Google).
123 ensureColumn('users', 'google_sub', 'TEXT');
124 // Read-only/viewer account: can view everything but make no changes.
125 ensureColumn('users', 'readonly', 'INTEGER DEFAULT 0');
126 // Personal interface language (nl|en|de). Null = follow the default (site/env/browser).
127 ensureColumn('users', 'lang', 'TEXT');
128 // Site-level moderation toggle. 'trust' = auto-approve, 'moderate' = pending until reviewed.
129 // Circles: whether this site may appear in other sites' circles (surfacing opt-out).
130 ensureColumn('sites', 'allow_circle', 'INTEGER DEFAULT 1');
131
132 // One EXPLICIT primary/main site (= the company/label site in hub mode,
133 // the only site in solo) instead of the fragile "oldest = main" convention
134 // that was duplicated in 4 places. Backfill: mark the oldest if no primary
135 // site exists yet, so existing behaviour is preserved exactly.
136 ensureColumn('sites', 'is_primary', 'INTEGER DEFAULT 0');
137 try {
138 const hasPrimary = db.prepare('SELECT 1 FROM sites WHERE is_primary = 1 LIMIT 1').get();
139 if (!hasPrimary) {
140 const oldest = db.prepare('SELECT id FROM sites ORDER BY created_at ASC LIMIT 1').get();
141 if (oldest) db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(oldest.id);
142 }
143 } catch (e) { /* sites table still empty/absent on fresh init โ€” ensurePrimarySite handles it */ }
144
145 // v9 audit additions โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”
146 // SEO/social columns the v9 template uses (most live in 001-init.sql already
147 // for fresh DBs but ensureColumn is idempotent for existing DBs).
148 ensureColumn('sites', 'twitter', 'TEXT'); // @handle (with @)
149 ensureColumn('sites', 'schema_type', "TEXT DEFAULT 'Person'"); // Person|Organization
150 ensureColumn('sites', 'publisher_name', 'TEXT');
151 ensureColumn('sites', 'publisher_url', 'TEXT');
152 ensureColumn('sites', 'publisher_logo', 'TEXT');
153 ensureColumn('sites', 'profile_enabled', 'INTEGER DEFAULT 1');
154 ensureColumn('sites', 'profile_name', 'TEXT'); // display name (falls back to title)
155 ensureColumn('sites', 'profile_bio', 'TEXT'); // short bio for header
156 ensureColumn('sites', 'profile_links', 'TEXT'); // JSON array [{platform, url}]
157 ensureColumn('sites', 'feed_view_default', "TEXT DEFAULT 'grid'"); // timeline | grid
158 ensureColumn('sites', 'feed_view_switch', 'INTEGER DEFAULT 1'); // show switcher
159 ensureColumn('sites', 'show_search', 'INTEGER DEFAULT 1');
160 ensureColumn('sites', 'show_archive_link', 'INTEGER DEFAULT 1');
161 // Gated feature (FEP-633c): may external (non-fediverse) embeds be shown to
162 // this account? NULL = auto, which means OFF for a ward and ON for anyone
163 // else. The guardians flip it; the gate itself lives server-side, so a ward
164 // never even receives the thumbnail it is not allowed to see.
165 ensureColumn('sites', 'external_embeds', 'INTEGER');
166 // De rest van de gate-familie (shaer-ahy.1, "maak ze allemaal functioneel",
167 // Barts opdracht 8-8). Zelfde drietal als external_embeds: NULL is de
168 // automatiek (dicht voor een ward, open voor de rest), 0/1 is een besluit
169 // van de guardians en wint van de automatiek.
170 ensureColumn('sites', 'external_threads', 'INTEGER'); // replies van vreemden onder een post (shaer-9y2)
171 ensureColumn('sites', 'gate_replies', 'INTEGER'); // zelf antwoorden in een gesprek (shaer-r4c)
172 ensureColumn('sites', 'gate_images', 'INTEGER'); // afbeeldingsbijlagen (shaer-6p5)
173 ensureColumn('sites', 'gate_messages', 'INTEGER'); // heel Messages (shaer-3ow)
174 ensureColumn('sites', 'gate_compose', 'INTEGER'); // zelf posten, de (+) kaart (shaer-qgev)
175 ensureColumn('sites', 'gate_music', 'INTEGER'); // audiobijlagen (shaer-rmz)
176 ensureColumn('sites', 'gate_quote_cards', 'INTEGER'); // ingebedde quote-kaarten (shaer-mls)
177 ensureColumn('sites', 'gate_custom_emoji', 'INTEGER'); // FEP-9098 emoji-plaatjes (shaer-ytw)
178 ensureColumn('sites', 'gate_account_move', 'INTEGER'); // FEP-7628 Move (shaer-tge)
179 // The heavier sibling (FEP-633c 5.6): may a player from outside this app run
180 // INSIDE it? A preview is a picture; playback hands the screen to a third
181 // party's engine, recommendations and all. Two settings, so the guardians can
182 // allow the one without the other. NULL = auto, which means off for a ward.
183 ensureColumn('sites', 'external_playback', 'INTEGER');
184 ensureColumn('sites', 'og_theme', 'TEXT'); // OG share-card variant: NULL=auto (follow site theme) | 'light' | 'dark'
185 // FEP-7628: former identities this actor claims (JSON array of actor URIs).
186 // Publishing them as alsoKnownAs is what lets the OLD server approve a Move
187 // of its followers to this account โ€” the claim must be visible on OUR side.
188 ensureColumn('sites', 'ap_aliases', 'TEXT');
189 ensureColumn('sites', 'moved_to', 'TEXT'); // FEP-7628 slice 2: waarheen dit account vertrok
190
191 // Per-post noindex + type
192 ensureColumn('posts', 'noindex', 'INTEGER DEFAULT 0');
193 ensureColumn('posts', 'publish_at', 'DATETIME'); // release planning (premium #3): scheduled go-live
194 ensureColumn('posts', 'fan_only', 'INTEGER DEFAULT 0'); // fan-only preview (premium #3)
195 ensureColumn('posts', 'nsfw', 'INTEGER DEFAULT 0'); // sensitive content โ†’ blur + click-to-reveal; fediverse sensitive
196 ensureColumn('posts', 'cover_video_url', 'TEXT'); // muted loop MP4 for an animated cover (Safari-smooth)
197 ensureColumn('posts', 'cover_alt', 'TEXT'); // alt text / description for the cover (a11y โ†’ AS2 attachment `name`)
198 ensureColumn('posts', 'language', 'TEXT'); // BCP-47 content language โ†’ federates as AS2 contentMap (Mastodon language filter/translate)
199 ensureColumn('posts', 'content_warning', 'TEXT'); // custom CW label (empty = default "Gevoelige inhoud")
200 ensureColumn('posts', 'type', "TEXT DEFAULT 'post'"); // post | foto | video | audio
201 ensureColumn('posts', 'poll_json', 'TEXT'); // a poll WE host โ†’ federates as AS2 Question: {multiple,options[{name}],endTime,closed}
202
203 // Statistics (premium module) โ€” bare counters, cookie-free.
204 ensureColumn('posts', 'view_count', 'INTEGER DEFAULT 0'); // views per post
205 ensureColumn('audio_tracks', 'play_count', 'INTEGER DEFAULT 0'); // plays per track
206 ensureColumn('audio_tracks', 'downloadable', 'INTEGER DEFAULT 0'); // download-for-email (premium #2)
207 ensureColumn('audio_tracks', 'credit', 'TEXT'); // owner/credit (copyright holder)
208 ensureColumn('audio_tracks', 'license', 'TEXT'); // license (e.g. "CC BY 4.0", "All rights reserved")
209 ensureColumn('audio_tracks', 'link_spotify', 'TEXT'); // "open in" links per track
210 ensureColumn('audio_tracks', 'link_youtube', 'TEXT');
211 ensureColumn('audio_tracks', 'link_soundcloud', 'TEXT');
212 // Per-track: federate the actual audio file as an AS2 Audio attachment so it plays inline
213 // in EVERY fediverse client (incl. the Mastodon apps). Default 0 = gated (web player only,
214 // file not exposed). Opt-in 1 = the file is served ungated + shared on the fediverse.
215 ensureColumn('audio_tracks', 'fedi_open', 'INTEGER DEFAULT 0');
216
217 // Playlists (v9 feature) โ€” first-class entity. CREATE IF NOT EXISTS is
218 // idempotent so it's safe to run on every boot regardless of DB age.
219 db.exec(`
220 CREATE TABLE IF NOT EXISTS playlists (
221 id TEXT PRIMARY KEY,
222 site_id TEXT NOT NULL,
223 title TEXT NOT NULL,
224 artist TEXT,
225 year INTEGER,
226 cover_url TEXT,
227 kind TEXT DEFAULT 'album',
228 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
229 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
230 FOREIGN KEY (site_id) REFERENCES sites(id)
231 );
232 CREATE TABLE IF NOT EXISTS playlist_tracks (
233 playlist_id TEXT NOT NULL,
234 track_id TEXT NOT NULL,
235 position INTEGER NOT NULL DEFAULT 0,
236 PRIMARY KEY (playlist_id, track_id),
237 FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
238 FOREIGN KEY (track_id) REFERENCES audio_tracks(id) ON DELETE CASCADE
239 );
240 CREATE INDEX IF NOT EXISTS idx_playlist_tracks_pos
241 ON playlist_tracks(playlist_id, position);
242 `);
243
244 // Global app settings (key/value singleton). Includes the tenancy mode
245 // (solo = one site, hub = company site + /user/). Default = solo.
246 db.exec(`
247 CREATE TABLE IF NOT EXISTS app_settings (
248 key TEXT PRIMARY KEY,
249 value TEXT,
250 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
251 );
252 `);
253 db.prepare("INSERT OR IGNORE INTO app_settings (key, value) VALUES ('tenancy', 'solo')").run();
254
255 // โ”€โ”€ Statistics (premium) โ€” cookie-free โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
256 // stat_daily: pageview count per day per site (bare counter).
257 // stat_visitor_day: one row per UNIQUE visitor hash per day per site
258 // (sha256 of IP+UA+day-salt; the salt rotates daily and is never stored
259 // โ†’ no persistent identifier, no cookie, no consent required).
260 db.exec(`
261 CREATE TABLE IF NOT EXISTS stat_daily (
262 site_id TEXT NOT NULL,
263 day TEXT NOT NULL,
264 pageviews INTEGER NOT NULL DEFAULT 0,
265 PRIMARY KEY (site_id, day)
266 );
267 CREATE TABLE IF NOT EXISTS stat_visitor_day (
268 site_id TEXT NOT NULL,
269 day TEXT NOT NULL,
270 visitor_hash TEXT NOT NULL,
271 PRIMARY KEY (site_id, day, visitor_hash)
272 );
273 CREATE INDEX IF NOT EXISTS idx_stat_visitor_day ON stat_visitor_day(site_id, day);
274 CREATE TABLE IF NOT EXISTS stat_referrer (
275 site_id TEXT NOT NULL,
276 host TEXT NOT NULL,
277 count INTEGER NOT NULL DEFAULT 0,
278 PRIMARY KEY (site_id, host)
279 );
280 `);
281
282 // Newsletter / mailing list (premium). Subscribers per site; double opt-in when SMTP
283 // is configured (status 'pending' until confirmed), otherwise single opt-in ('confirmed').
284 // 'unsub' = unsubscribed. token = confirm/unsubscribe key (used in email links).
285 db.exec(`
286 CREATE TABLE IF NOT EXISTS subscribers (
287 id TEXT PRIMARY KEY,
288 site_id TEXT NOT NULL,
289 email TEXT NOT NULL,
290 status TEXT NOT NULL DEFAULT 'pending',
291 source TEXT DEFAULT 'widget',
292 token TEXT NOT NULL,
293 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
294 confirmed_at DATETIME,
295 UNIQUE(site_id, email)
296 );
297 CREATE INDEX IF NOT EXISTS idx_subscribers_site_status ON subscribers(site_id, status);
298 `);
299
300 // Sent newsletters (history + counts).
301 db.exec(`
302 CREATE TABLE IF NOT EXISTS newsletters (
303 id TEXT PRIMARY KEY,
304 site_id TEXT NOT NULL,
305 subject TEXT NOT NULL,
306 body TEXT NOT NULL,
307 sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
308 recipient_count INTEGER DEFAULT 0
309 );
310 `);
311
312 // Show agenda (premium #8): tour dates / gigs per site.
313 db.exec(`
314 CREATE TABLE IF NOT EXISTS shows (
315 id TEXT PRIMARY KEY,
316 site_id TEXT NOT NULL,
317 date TEXT NOT NULL,
318 time TEXT,
319 city TEXT NOT NULL,
320 venue TEXT,
321 country TEXT,
322 ticket_url TEXT,
323 notes TEXT,
324 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
325 );
326 CREATE INDEX IF NOT EXISTS idx_shows_site_date ON shows(site_id, date);
327 `);
328
329 // Link-in-bio click statistics (premium #6). One counter per (site, url); the
330 // link-in-bio page links via /links/go/:i which counts the click and redirects.
331 db.exec(`
332 CREATE TABLE IF NOT EXISTS link_clicks (
333 site_id TEXT NOT NULL,
334 url TEXT NOT NULL,
335 clicks INTEGER DEFAULT 0,
336 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
337 PRIMARY KEY (site_id, url)
338 );
339 `);
340
341
342 // โ”€โ”€ ActivityPub (fediverse bridge) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
343 // RSA keypair per actor (Mastodon-compatible HTTP Signatures; separate from
344 // the Cirkels Ed25519 keys). ap_followers = remote AP actors following us.
345 db.exec(`
346 CREATE TABLE IF NOT EXISTS ap_keys (
347 slug TEXT PRIMARY KEY,
348 public_pem TEXT NOT NULL,
349 private_pem TEXT NOT NULL,
350 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
351 );
352 CREATE TABLE IF NOT EXISTS ap_followers (
353 id INTEGER PRIMARY KEY AUTOINCREMENT,
354 slug TEXT NOT NULL,
355 actor_uri TEXT NOT NULL,
356 inbox TEXT,
357 shared_inbox TEXT,
358 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
359 UNIQUE(slug, actor_uri)
360 );
361 CREATE INDEX IF NOT EXISTS idx_ap_followers_slug ON ap_followers(slug);
362 CREATE TABLE IF NOT EXISTS ap_interactions (
363 id INTEGER PRIMARY KEY AUTOINCREMENT,
364 kind TEXT NOT NULL, -- 'reply' | 'like' | 'announce'
365 post_id TEXT NOT NULL,
366 object_uri TEXT NOT NULL DEFAULT '', -- remote note id (reply) or '' (like/announce)
367 actor_uri TEXT NOT NULL,
368 actor_name TEXT,
369 actor_handle TEXT,
370 actor_url TEXT,
371 actor_icon TEXT,
372 content TEXT, -- sanitized HTML (reply)
373 published TEXT,
374 parent_uri TEXT, -- the note this reply replies to (for nesting)
375 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
376 UNIQUE(kind, post_id, actor_uri, object_uri)
377 );
378 CREATE INDEX IF NOT EXISTS idx_ap_inter_post ON ap_interactions(post_id, kind);
379 -- Moderation tombstones: object URIs the site owner removed. Checked at ingest
380 -- (handleInbox) AND by the thread-crawler, so a removed reply never comes back
381 -- via thread-filling. Private notes can't be flagged via authorize_interaction
382 -- (their fetch 401s), so owner moderation acts on the locally stored copy.
383 CREATE TABLE IF NOT EXISTS ap_rejected_objects (
384 object_uri TEXT PRIMARY KEY,
385 post_id TEXT,
386 reason TEXT,
387 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
388 );
389 -- ActivityPub C2S (client-to-server): OAuth 2.0 for native/web clients (Shaer).
390 -- Public clients + PKCE (RFC 8252); tokens stored hashed; token is per user+site.
391 CREATE TABLE IF NOT EXISTS oauth_clients (
392 client_id TEXT PRIMARY KEY,
393 client_name TEXT,
394 redirect_uris TEXT NOT NULL, -- JSON array
395 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
396 );
397 CREATE TABLE IF NOT EXISTS oauth_codes (
398 code TEXT PRIMARY KEY,
399 client_id TEXT NOT NULL,
400 user_id TEXT NOT NULL,
401 site_slug TEXT NOT NULL,
402 redirect_uri TEXT NOT NULL,
403 code_challenge TEXT, -- PKCE S256 (verplicht voor public clients)
404 scope TEXT,
405 expires_at DATETIME NOT NULL,
406 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
407 );
408 CREATE TABLE IF NOT EXISTS oauth_tokens (
409 token_hash TEXT PRIMARY KEY, -- sha256(bearer); het token zelf slaan we nooit op
410 client_id TEXT NOT NULL,
411 user_id TEXT NOT NULL,
412 site_slug TEXT NOT NULL,
413 scope TEXT,
414 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
415 last_used_at DATETIME
416 );
417 -- Paid posts (klonkt-demo-aki): the site owner's own Patreon campaign.
418 -- Secrets are encrypted at rest (CryptoBox). Never reuses the instance-level
419 -- patreon_* settings, which are Klonkt Premium's separate license flow.
420 CREATE TABLE IF NOT EXISTS paid_patreon (
421 site_id TEXT PRIMARY KEY,
422 client_id TEXT,
423 client_secret_enc TEXT,
424 campaign_id TEXT,
425 access_token_enc TEXT,
426 refresh_token_enc TEXT,
427 token_exp INTEGER, -- unix seconds
428 default_min_cents INTEGER DEFAULT 0,
429 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
430 );
431 -- One row per passkey. NO patron identity is stored (design decision):
432 -- {passkey, site, proven cents, expiry}. Not traceable to a person.
433 CREATE TABLE IF NOT EXISTS paid_entitlements (
434 credential_id TEXT PRIMARY KEY, -- WebAuthn credential id (opaque, base64url)
435 site_id TEXT NOT NULL,
436 public_key TEXT NOT NULL, -- COSE public key, base64url
437 counter INTEGER DEFAULT 0,
438 transports TEXT,
439 min_cents INTEGER DEFAULT 0, -- the amount proven at link time
440 expires_at INTEGER NOT NULL, -- unix seconds; re-link after
441 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
442 );
443 -- Web Push (docs/webpush-design.md): one row per browser/device the owner
444 -- enabled notifications on. Payloads are encrypted to p256dh/auth (RFC 8291).
445 CREATE TABLE IF NOT EXISTS push_subscriptions (
446 endpoint TEXT PRIMARY KEY, -- push-service URL for this device
447 user_id TEXT NOT NULL,
448 p256dh TEXT NOT NULL, -- client public key
449 auth TEXT NOT NULL, -- client auth secret
450 alert_types TEXT, -- JSON {follow,reply,like,boost,dm}
451 ua_label TEXT,
452 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
453 last_ok_at DATETIME
454 );
455 CREATE TABLE IF NOT EXISTS ap_outbox (
456 id TEXT PRIMARY KEY, -- note path segment (uuid) โ†’ /ap/notes/<id>
457 site_slug TEXT NOT NULL,
458 post_id TEXT NOT NULL,
459 post_slug TEXT,
460 in_reply_to TEXT, -- remote status uri we reply to
461 to_actor TEXT, -- remote actor uri (mentioned)
462 to_handle TEXT,
463 content TEXT NOT NULL, -- sanitized HTML of our reply
464 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
465 );
466 CREATE INDEX IF NOT EXISTS idx_ap_outbox_post ON ap_outbox(post_id);
467 -- Your like/boost state on a REMOTE post (the interact page), so those become toggles.
468 CREATE TABLE IF NOT EXISTS ap_my_reactions (
469 site_slug TEXT NOT NULL,
470 target_uri TEXT NOT NULL,
471 kind TEXT NOT NULL, -- 'like' | 'boost'
472 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
473 UNIQUE(site_slug, target_uri, kind)
474 );
475 `);
476 ensureColumn('ap_interactions', 'parent_uri', 'TEXT'); // nesting (existing DBs)
477 ensureColumn('ap_interactions', 'acted_boost', 'INTEGER DEFAULT 0'); // owner boosted this comment (๐Ÿ”) โ†’ can undo
478 ensureColumn('ap_interactions', 'acted_like', 'INTEGER DEFAULT 0'); // owner liked this comment (โญ) โ†’ can undo
479
480 // Fediverse CLIENT: accounts WE follow (outbound) + the home timeline of their posts.
481 db.exec(`
482 CREATE TABLE IF NOT EXISTS ap_following (
483 id INTEGER PRIMARY KEY AUTOINCREMENT,
484 slug TEXT NOT NULL, -- our site that follows
485 actor_uri TEXT NOT NULL, -- the followed account's actor id
486 handle TEXT, name TEXT, icon TEXT, url TEXT,
487 inbox TEXT, -- their inbox (for Create delivery / Undo)
488 follow_id TEXT, -- the Follow activity id we sent (Accept matching)
489 status TEXT DEFAULT 'pending', -- pending | accepted
490 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
491 UNIQUE(slug, actor_uri)
492 );
493 -- Antwoorden van accounts die we volgen komen gewoon binnen, ondertekend
494 -- door de schrijver, maar horen niet in de Krant (belongsInTimeline) en
495 -- werden daarna nergens bewaard. Kwam er later een doorgestuurd antwoord OP
496 -- zo'n bericht, dan kenden we de ouder niet en wezen we het af (shaer-e9g).
497 -- Alleen de URI, geen inhoud: dit voedt uitsluitend de vraag "kennen wij dit
498 -- bericht?". Wordt na 30 dagen gesnoeid; doorsturen gebeurt kort na het
499 -- antwoord, dus langer bewaren levert niets op.
500 CREATE TABLE IF NOT EXISTS ap_seen_notes (
501 uri TEXT PRIMARY KEY,
502 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
503 );
504 CREATE INDEX IF NOT EXISTS idx_ap_seen_notes_age ON ap_seen_notes(created_at);
505 CREATE TABLE IF NOT EXISTS ap_timeline (
506 id TEXT NOT NULL, -- the remote note's AP id
507 slug TEXT NOT NULL, -- whose home timeline (our site)
508 author_uri TEXT, author_name TEXT, author_handle TEXT, author_icon TEXT, author_url TEXT,
509 content TEXT, url TEXT, published TEXT, media_json TEXT,
510 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
511 UNIQUE(slug, id)
512 );
513 CREATE INDEX IF NOT EXISTS idx_ap_timeline_slug ON ap_timeline(slug, published);
514 -- canonicalReactionUri herleidt een permalink naar het object-id door op (slug, url)
515 -- te zoeken. Zonder deze index viel dat terug op idx_ap_timeline_slug, dus een scan
516 -- van elke rij van die slug. Dat gebeurt PER REACTIE in getInteractions, en de
517 -- reactie-migratie erft het in haar re-key-join, die synchroon vรณรณr listen draait:
518 -- de opstartkosten waren reacties maal tijdlijnrijen.
519 CREATE INDEX IF NOT EXISTS idx_ap_timeline_url ON ap_timeline(slug, url);
520 CREATE TABLE IF NOT EXISTS ap_blocks (
521 id INTEGER PRIMARY KEY AUTOINCREMENT,
522 slug TEXT NOT NULL, -- our site that set the block
523 target TEXT NOT NULL, -- actor URI (actor block) or domain (domain block)
524 kind TEXT NOT NULL, -- 'actor' | 'domain'
525 label TEXT, -- display (@handle or domain)
526 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
527 UNIQUE(slug, target)
528 );
529 CREATE INDEX IF NOT EXISTS idx_ap_blocks_target ON ap_blocks(target);
530 -- Committed guardian โ†” ward relations, one row per local side. role
531 -- 'ward' = the local slug is a ward of other_uri; 'guardian' = the local
532 -- slug guards other_uri. status is always 'accepted' here now: PENDING
533 -- offers live in ap_guardian_offers below (FEP-633c multi-party handshake).
534 CREATE TABLE IF NOT EXISTS ap_guardianships (
535 id INTEGER PRIMARY KEY AUTOINCREMENT,
536 slug TEXT NOT NULL, -- our local site in this relation (guardianship module)
537 role TEXT NOT NULL, -- 'guardian' (slug guards other) | 'ward' (other guards slug)
538 other_uri TEXT NOT NULL, -- the counterpart actor URI (local or remote)
539 other_handle TEXT, -- cached @user@host for display
540 status TEXT NOT NULL, -- 'offered' (legacy) | 'accepted'
541 offer_id TEXT, -- the Offer activity id (FEP-633c section 3)
542 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
543 UNIQUE(slug, role, other_uri)
544 );
545 CREATE INDEX IF NOT EXISTS idx_ap_guardianships_slug ON ap_guardianships(slug, role, status);
546 -- The multi-party handshake (FEP-633c section 3), one row per offer this
547 -- instance is a party to. Mirrors the Shaer test daemon's Handshake:
548 -- accepts accumulate in ap_guardian_offer_accepts, and the offer commits
549 -- only when the candidate returns the handle after ward + candidate + at
550 -- least one existing guardian have accepted.
551 CREATE TABLE IF NOT EXISTS ap_guardian_offers (
552 offer_id TEXT NOT NULL, -- the Offer activity id (minted by the candidate)
553 slug TEXT NOT NULL, -- the local site tracking this handshake (each party keeps its own copy)
554 ward_uri TEXT NOT NULL, -- the ward-to-be
555 candidate_uri TEXT NOT NULL, -- the guardian-candidate (fixed initiator)
556 existing_guardians TEXT NOT NULL DEFAULT '[]', -- JSON array of the ward's current guardian URIs
557 status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'committed' | 'void'
558 handle TEXT, -- the escalation handle returned at commit (section 6)
559 ward_handle TEXT, -- cached @ward@host for display
560 candidate_handle TEXT, -- cached @candidate@host for display
561 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
562 PRIMARY KEY (slug, offer_id)
563 );
564 CREATE INDEX IF NOT EXISTS idx_ap_guardian_offers_slug ON ap_guardian_offers(slug, status);
565 CREATE TABLE IF NOT EXISTS ap_guardian_offer_accepts (
566 offer_id TEXT NOT NULL, -- FK to ap_guardian_offers
567 slug TEXT NOT NULL, -- the local site's copy of the tally
568 party_uri TEXT NOT NULL, -- the party who accepted (ward | candidate | an existing guardian)
569 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
570 PRIMARY KEY (slug, offer_id, party_uri)
571 );
572 -- FEP-633c ยง5.6: a gated setting a ward's guardians decide together, which
573 -- has to work when they live on other servers (the ordinary case). One row
574 -- per guardian answer; the ward's server tallies (ยง3.5) and enforces.
575 -- The proposals themselves, so an Accept that only references the offer
576 -- id can still be resolved to "which feature, which value".
577 CREATE TABLE IF NOT EXISTS ap_gated_offers (
578 offer_id TEXT PRIMARY KEY,
579 slug TEXT NOT NULL, -- the ward, on this server
580 feature TEXT NOT NULL,
581 value INTEGER NOT NULL,
582 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
583 );
584 -- The guardian-side COPY of a gated-setting proposal on a ward, forwarded
585 -- here by the WARD's server (the same shape ap_follow_reviews has for a
586 -- gated follow). Without it a guardian on another server never learns a
587 -- proposal exists and can never answer it, so a threshold of two can never
588 -- be reached and every proposal expires. The answer goes back to the
589 -- ward's inbox, which tallies (5.6).
590 CREATE TABLE IF NOT EXISTS ap_gated_reviews (
591 id TEXT NOT NULL, -- the offer id, as minted by the proposer
592 guardian_slug TEXT NOT NULL, -- us, one of the ward's guardians
593 ward_uri TEXT NOT NULL,
594 ward_inbox TEXT,
595 proposer TEXT, -- who opened it (for display)
596 feature TEXT NOT NULL,
597 value INTEGER NOT NULL,
598 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
599 PRIMARY KEY (guardian_slug, id)
600 );
601 -- The PROPOSER's own record of a gated proposal it sent (5.6). Without it
602 -- a guardian clicks "propose", the ward's server tallies somewhere else,
603 -- and the proposer has nowhere to even see that something is running: the
604 -- status was a button caption that did not survive a page refresh. The
605 -- ward's server answers the Offer once the decision settles (Accept when
606 -- it settled on the proposed value, Reject otherwise); that answer lands
607 -- in status. An open row past the decision window renders as expired.
608 CREATE TABLE IF NOT EXISTS ap_gated_sent (
609 offer_id TEXT PRIMARY KEY, -- as minted by us, the proposer
610 guardian_slug TEXT NOT NULL, -- us
611 ward_uri TEXT NOT NULL,
612 feature TEXT NOT NULL,
613 value INTEGER NOT NULL, -- what we proposed
614 status TEXT NOT NULL DEFAULT 'open', -- 'open' | 'accepted' | 'rejected'
615 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
616 );
617 CREATE TABLE IF NOT EXISTS ap_gated_votes (
618 slug TEXT NOT NULL, -- the WARD, on this server
619 feature TEXT NOT NULL, -- e.g. 'shaer:externalEmbeds'
620 guardian_uri TEXT NOT NULL, -- who answered (must be a committed guardian)
621 value INTEGER NOT NULL, -- the value they voted for (0/1)
622 opened_at DATETIME NOT NULL, -- when this decision opened (the window start)
623 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
624 PRIMARY KEY (slug, feature, guardian_uri)
625 );
626 -- Guardian availability (FEP-633c 3.6): one guardian's attention as seen
627 -- from one ward on this server. Never public; the ward reads it via the
628 -- owner-only guardians queue. One rule above all: one answer restores
629 -- everything, so every row here is one answer away from disappearing.
630 CREATE TABLE IF NOT EXISTS ap_guardian_attention (
631 ward_slug TEXT NOT NULL,
632 guardian_uri TEXT NOT NULL,
633 state TEXT NOT NULL DEFAULT 'active', -- 'active' | 'away' | 'dormant'
634 away_until INTEGER, -- epoch ms while declared away
635 PRIMARY KEY (ward_slug, guardian_uri)
636 );
637 -- The ONLY admissible dormancy evidence (3.6.2): directly addressed
638 -- requests that went unanswered. Calendar time alone never counts.
639 CREATE TABLE IF NOT EXISTS ap_attention_requests (
640 ward_slug TEXT NOT NULL,
641 guardian_uri TEXT NOT NULL,
642 request_id TEXT NOT NULL,
643 asked_at INTEGER NOT NULL, -- epoch ms
644 PRIMARY KEY (ward_slug, guardian_uri, request_id)
645 );
646 -- A lapse (3.6.3): the available co-guardians deciding to release a
647 -- dormant one. Irreversible, so the window always runs in full; any sign
648 -- of life from the target cancels it outright.
649 CREATE TABLE IF NOT EXISTS ap_lapses (
650 id TEXT PRIMARY KEY,
651 ward_slug TEXT NOT NULL,
652 ward_uri TEXT NOT NULL,
653 target_uri TEXT NOT NULL,
654 opened_by TEXT NOT NULL,
655 set_json TEXT NOT NULL, -- the available set at open, target excluded
656 accepts_json TEXT NOT NULL DEFAULT '[]',
657 rejects_json TEXT NOT NULL DEFAULT '[]',
658 opened_at INTEGER NOT NULL, -- epoch ms
659 window_ms INTEGER NOT NULL,
660 cancelled INTEGER NOT NULL DEFAULT 0,
661 applied INTEGER NOT NULL DEFAULT 0,
662 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
663 );
664 CREATE TABLE IF NOT EXISTS ap_delivery (
665 id INTEGER PRIMARY KEY AUTOINCREMENT,
666 slug TEXT NOT NULL, -- our site/actor that signs the delivery
667 inbox TEXT NOT NULL, -- recipient inbox URL
668 body TEXT NOT NULL, -- the activity JSON to POST
669 attempts INTEGER NOT NULL DEFAULT 0,
670 next_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
671 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
672 );
673 CREATE INDEX IF NOT EXISTS idx_ap_delivery_due ON ap_delivery(next_at);
674 CREATE TABLE IF NOT EXISTS poll_votes (
675 id INTEGER PRIMARY KEY AUTOINCREMENT,
676 post_id INTEGER NOT NULL, -- our local poll post (posts.id)
677 actor_uri TEXT NOT NULL, -- the remote voter's AP actor URI
678 choice TEXT NOT NULL, -- the chosen option's name (matches poll_json options[].name)
679 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
680 UNIQUE(post_id, actor_uri, choice)
681 );
682 CREATE INDEX IF NOT EXISTS idx_poll_votes_post ON poll_votes(post_id);
683 CREATE TABLE IF NOT EXISTS ap_mentions (
684 id INTEGER PRIMARY KEY AUTOINCREMENT,
685 slug TEXT NOT NULL, -- our mentioned site/actor
686 object_uri TEXT NOT NULL, -- the remote note that mentions us
687 note_url TEXT, -- its human URL (open/interact)
688 actor_uri TEXT, actor_name TEXT, actor_handle TEXT, actor_icon TEXT, actor_url TEXT,
689 content TEXT, -- sanitized HTML snippet of the mentioning note
690 published TEXT,
691 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
692 UNIQUE(slug, object_uri)
693 );
694 CREATE INDEX IF NOT EXISTS idx_ap_mentions_slug ON ap_mentions(slug, created_at);
695 CREATE TABLE IF NOT EXISTS ap_reports (
696 id INTEGER PRIMARY KEY AUTOINCREMENT,
697 slug TEXT NOT NULL, -- our site the report is about (its owner moderates)
698 actor_uri TEXT, -- the reporter's actor URI
699 actor_name TEXT, actor_handle TEXT, actor_icon TEXT,
700 content TEXT, -- the reason (plain text)
701 objects TEXT, -- JSON array of reported object URIs (our actor + statuses)
702 seen INTEGER DEFAULT 0,
703 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
704 );
705 CREATE INDEX IF NOT EXISTS idx_ap_reports_slug ON ap_reports(slug, created_at);
706 `);
707 // "Feature" a followed account: its posts show in the local Cirkel.
708 ensureColumn('ap_following', 'auto_boost', 'INTEGER DEFAULT 0');
709 // A timeline post you boosted (๐Ÿ”) โ€” also shown in the Cirkel (mixed by date).
710 ensureColumn('ap_timeline', 'boosted', 'INTEGER DEFAULT 0');
711 ensureColumn('ap_timeline', 'liked', 'INTEGER DEFAULT 0'); // a feed post you liked (โญ) โ†’ toggle
712 ensureColumn('ap_timeline', 'nsfw', 'INTEGER DEFAULT 0'); // remote sensitive post โ†’ blur in the Cirkel
713 ensureColumn('ap_timeline', 'cw', 'TEXT'); // remote content-warning text
714 ensureColumn('ap_timeline', 'emoji_json', 'TEXT'); // FEP-9098 custom emoji Emoji tags from the inbound note, served back as `tag`
715 ensureColumn('ap_timeline', 'link_json', 'TEXT'); // FEP-e232 object-link (quote/ref) tags from the inbound note, served back as `tag`
716 ensureColumn('ap_timeline', 'quote_json', 'TEXT'); // FEP-044f resolved quoted-post snapshot (author + content), for the embedded quote card
717 // FEP-044f: the fediverse object THIS post quotes, resolved once at publish
718 // time so buildNote (sync, also used by the outbox) needs no network.
719 ensureColumn('posts', 'quote_uri', 'TEXT'); // the quoted object's id
720 ensureColumn('posts', 'quote_actor', 'TEXT'); // its author, so we can address them
721 ensureColumn('ap_timeline', 'embed_json', 'TEXT'); // resolved EXTERNAL embed (oEmbed/provider), thumbnail-only; gated per site (sites.external_embeds)
722 ensureColumn('ap_timeline', 'author_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the author's display name (shaer:author.emojis)
723 ensureColumn('ap_timeline', 'reblog_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the booster's display name (shaer:booster.emojis)
724 ensureColumn('ap_timeline', 'reblog_name', 'TEXT'); // a followed account boosted this โ†’ "X boosted"
725 ensureColumn('ap_timeline', 'reblog_handle', 'TEXT'); // the booster's @handle
726 ensureColumn('ap_timeline', 'reblog_icon', 'TEXT'); // the booster's avatar
727 ensureColumn('ap_timeline', 'poll_json', 'TEXT'); // a Question (poll): {multiple,options[{name,count}],endTime,closed,voters,voted}
728
729 // Delivery health per follower โ†’ surface dead accounts for manual cleanup.
730 ensureColumn('ap_followers', 'last_delivery_at', 'DATETIME'); // last SUCCESSFUL delivery to this follower's inbox
731 ensureColumn('ap_followers', 'last_error_at', 'DATETIME'); // last time a delivery to it gave up (max retries)
732
733 // ActivityPub `source` model: content_rendered = baked display HTML (#hashtags / URLs /
734 // @mentions linkified once at save). `content` stays the raw source used for editing and
735 // re-rendering. NULL on old posts โ†’ the render route bakes on the fly as a fallback.
736 ensureColumn('posts', 'content_rendered', 'TEXT');
737
738 // AP addressing of an incoming interaction: 'public' | 'unlisted' | 'followers' | 'direct',
739 // derived from the note's to/cc at ingest. The public post page only renders public/unlisted
740 // replies; followers/direct replies surface in notifications (and later Messages) with post
741 // context instead. Existing rows default to 'public' (historically almost all were).
742 ensureColumn('ap_interactions', 'visibility', "TEXT DEFAULT 'public'");
743 ensureColumn('ap_interactions', 'emoji_json', 'TEXT'); // FEP-9098 custom emojis in a reply's content (messages + thread)
744 ensureColumn('ap_interactions', 'actor_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the reply author's display name
745 // Rich replies: the reply's language (BCP47 code) โ†’ contentMap on the outgoing Note.
746 ensureColumn('ap_outbox', 'language', 'TEXT');
747 // Rich replies: JSON array [{url, mediaType, name}] โ†’ `attachment` on the Note.
748 ensureColumn('ap_outbox', 'attachments', 'TEXT');
749 ensureColumn('posts', 'ap_visibility', 'TEXT'); // public|quiet|friends|direct (C2S addressing, shaer-60b)
750 ensureColumn('posts', 'paid', 'INTEGER DEFAULT 0'); // paid post (klonkt-demo-aki)
751 ensureColumn('posts', 'paid_min_cents', 'INTEGER'); // required support; null = owner default
752 ensureColumn('paid_patreon', 'patreon_url', 'TEXT'); // owner's public Patreon page โ†’ "Word supporter" link (klonkt-demo-aki)
753 ensureColumn('ap_outbox', 'visibility', 'TEXT'); // 'direct' = private mention, never Public (shaer-tqc)
754 ensureColumn('ap_outbox', 'to_actors', 'TEXT'); // JSON array of recipient actor URIs for direct notes
755 ensureColumn('ap_outbox', 'help_request', 'INTEGER'); // FEP-633c shaer:helpRequest (ward's call for help)
756 // Wie er op een hulpvraag af is, en wanneer hij is afgesloten (shaer-lgo).
757 // Los van ap_mentions, want dit is GEDEELDE staat: elke guardian van dit kind
758 // heeft er een kopie van, en die komt binnen als bericht van een ander. Een
759 // kolom op de mention zou alleen over onszelf gaan.
760 //
761 // OPGEPIKT mag stapelen: twee mensen die tegelijk reageren op een kind dat om
762 // hulp vraagt is geen probleem. Twee mensen die allebei niets doen omdat de
763 // ander het "geclaimd" had, wel.
764 //
765 // AFGEHANDELD kent geen terugdraai. Sluiten gebeurt met een stevige
766 // bevestiging, en leeft de vraag daarna nog, dan wordt hij opnieuw gesteld --
767 // een nieuwe hulpvraag. Zo blijft het verslag eerlijk: er wordt niets
768 // herschreven, er wordt toegevoegd.
769 db.exec(`CREATE TABLE IF NOT EXISTS ap_help_state (
770 note_uri TEXT NOT NULL,
771 guardian_uri TEXT NOT NULL,
772 kind TEXT NOT NULL, -- pickup | handled
773 guardian_handle TEXT,
774 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
775 PRIMARY KEY (note_uri, guardian_uri, kind)
776 )`);
777 db.exec('CREATE INDEX IF NOT EXISTS idx_ap_help_state_note ON ap_help_state(note_uri)');
778 ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
779 ensureColumn('ap_outbox', 'wave', 'INTEGER'); // FEP-633c shaer:wave (guardian -> ward nudge)
780 ensureColumn('ap_outbox', 'away_until', 'INTEGER'); // FEP-633c 3.6.1 shaer:away + endTime (epoch ms)
781 ensureColumn('ap_gated_offers', 'proposer', 'TEXT'); // who proposed (5.6): the settle-answer goes back to them
782 // Did a guardian actually say yes to this follower? That is what makes the
783 // mutual shortcut sound: a ward may follow back anyone its guardians already
784 // admitted, without asking the same question twice. Only follows that came
785 // through the ยง5.3 gate carry the mark; a free actor's followers never faced
786 // one. Everyone already following when this column arrives is grandfathered
787 // in (Barts besluit, 3-8): the rule is exact from that moment forward rather
788 // than retroactively suspicious of relationships that already exist.
789 {
790 const had = db.prepare("SELECT COUNT(*) AS n FROM pragma_table_info('ap_followers') WHERE name = 'gate_approved'").get();
791 ensureColumn('ap_followers', 'gate_approved', 'INTEGER DEFAULT 0');
792 if (!had || !had.n) {
793 try { db.prepare('UPDATE ap_followers SET gate_approved = 1').run(); } catch { /* table still empty on a fresh init */ }
794 }
795 }
796 ensureColumn('posts', 'c2s_attachments', 'TEXT'); // media a C2S Note carried (JSON [{url,mediaType,name}]); buildNote federates them
797 // 30-7: C2S posts briefly got their content media copied onto the cover,
798 // which showed the same video twice on the post page. Clear the covers that
799 // duplicate their own content; idempotent, only ever touches those.
800 try {
801 db.prepare("UPDATE posts SET cover_video_url = NULL WHERE cover_video_url LIKE '/media/reply-media/%' AND instr(content, cover_video_url) > 0").run();
802 db.prepare("UPDATE posts SET cover_image_url = NULL WHERE cover_image_url LIKE '/media/reply-media/%' AND instr(content, cover_image_url) > 0").run();
803 } catch { /* posts table absent on fresh init */ }
804 ensureColumn('ap_mentions', 'wave', 'INTEGER'); // inbound guardian wave
805 // FEP-633c ยง2.2: object hint that the author is a ward. Register-only for now;
806 // used later at reddings-boei / escalation routing.
807 ensureColumn('ap_timeline', 'has_guardians', 'INTEGER');
808 ensureColumn('ap_mentions', 'has_guardians', 'INTEGER');
809 // Berichten and de Krant render a post the same way, so a mention or a reply
810 // needs the same trimmings a timeline row already has: custom emojis, the
811 // media the note carried, and the quote / link-preview card.
812 ensureColumn('ap_mentions', 'emoji_json', 'TEXT'); // FEP-9098, in the content
813 ensureColumn('ap_mentions', 'actor_emoji_json', 'TEXT'); // FEP-9098, in the display name
814 ensureColumn('ap_mentions', 'media_json', 'TEXT');
815 ensureColumn('ap_mentions', 'quote_json', 'TEXT'); // FEP-044f quoted post
816 ensureColumn('ap_mentions', 'embed_json', 'TEXT'); // external link preview
817 ensureColumn('ap_interactions', 'media_json', 'TEXT');
818 ensureColumn('ap_interactions', 'quote_json', 'TEXT');
819 ensureColumn('ap_interactions', 'embed_json', 'TEXT');
820 ensureColumn('ap_followers', 'name', 'TEXT'); // cached display name (shaer-aa3)
821 ensureColumn('ap_followers', 'handle', 'TEXT'); // @user@host
822 ensureColumn('ap_followers', 'icon', 'TEXT'); // avatar URL
823 feedStateTriggers();
824}
825
826/**
827 * Wat er met een tijdlijn gebeurd is, op รฉรฉn plek (shaer-n05).
828 *
829 * De inbox-lezing voegt vier bronnen samen. De vraag "is er iets veranderd" werd
830 * eerst beantwoord met MAX(rowid) over die vier -- een TOEVALLIGE eigenschap van
831 * de tabellen, geen feit dat ergens is opgeschreven. Dat gaf precies de gebreken
832 * die je van zo'n afleiding verwacht: bewerkingen en verwijderingen bewogen hem
833 * niet, en hij kon achteruit lopen. Dezelfde fout als reacties uitlezen uit
834 * ap_timeline.liked (shaer-9e9).
835 *
836 * Nu รฉรฉn rij per bericht per tijdlijn, met een oplopende `rev` en `kind`. Dat
837 * beantwoordt drie vragen die anders drie eigen oplossingen zouden krijgen:
838 * is er iets veranderd sinds N, wรกt is er veranderd, en is dit bericht bewerkt.
839 *
840 * Bijgehouden door TRIGGERS en niet door de aanroepende code, om dezelfde reden
841 * dat er geen gebeurtenis-emitter is: een trigger zit in de database, dus geen
842 * enkel codepad kan hem vergeten. De prijs is onzichtbare logica -- wie alleen de
843 * JavaScript leest ziet niet waarom deze tabel vult. Vandaar dat ze hier staan,
844 * bij de tabel, en niet verspreid.
845 *
846 * Let op de `UPDATE OF`-kolomlijsten: die zijn niet decoratief. Een like schrijft
847 * ap_timeline.liked en een ๐Ÿ” schrijft .boosted; zonder die afbakening zou je
848 * eigen like het bericht als BEWERKT merken en elke wachtende client wekken.
849 */
850function feedStateTriggers() {
851 try {
852 db.exec(`
853 CREATE TABLE IF NOT EXISTS ap_feed_state (
854 slug TEXT NOT NULL,
855 object_uri TEXT NOT NULL,
856 rev INTEGER NOT NULL,
857 kind TEXT NOT NULL, -- new | updated | deleted
858 at DATETIME DEFAULT CURRENT_TIMESTAMP,
859 PRIMARY KEY (slug, object_uri)
860 );
861 CREATE INDEX IF NOT EXISTS idx_ap_feed_state_rev ON ap_feed_state(slug, rev);
862 -- Eรฉn doorlopende teller voor de hele instance. Bewust niet MAX(rev) uit de
863 -- tabel zelf: verdwijnt de hoogste rij, dan zou die teruglopen en denkt een
864 -- client dat er niets gebeurd is.
865 CREATE TABLE IF NOT EXISTS ap_feed_rev (n INTEGER NOT NULL);
866 `);
867 if (!db.prepare('SELECT COUNT(*) AS n FROM ap_feed_rev').get().n) {
868 db.prepare('INSERT INTO ap_feed_rev (n) VALUES (0)').run();
869 }
870 // slug + object_uri verschillen per bron; de rest is voor alle vier gelijk.
871 const zet = (naam, gebeurtenis, tabel, slug, uri, kind, extra = '', wanneer = '') => `
872 DROP TRIGGER IF EXISTS ${naam};
873 CREATE TRIGGER ${naam} AFTER ${gebeurtenis} ON ${tabel}${wanneer ? ` WHEN ${wanneer}` : ''} BEGIN
874 UPDATE ap_feed_rev SET n = n + 1;
875 INSERT INTO ap_feed_state (slug, object_uri, rev, kind)
876 ${extra || `VALUES (${slug}, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}')`}
877 ON CONFLICT(slug, object_uri) DO UPDATE
878 SET rev = excluded.rev, kind = excluded.kind, at = CURRENT_TIMESTAMP;
879 END;`;
880 const joinPosts = (uri, kind) => `
881 SELECT s.slug, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}'
882 FROM posts p JOIN sites s ON s.id = p.site_id`;
883 db.exec([
884 zet('trg_feed_tl_ins', 'INSERT', 'ap_timeline', 'NEW.slug', 'NEW.id', 'new'),
885 zet('trg_feed_tl_upd', 'UPDATE OF content, media_json, nsfw, cw, url, poll_json, quote_json, embed_json', 'ap_timeline', 'NEW.slug', 'NEW.id', 'updated'),
886 zet('trg_feed_tl_del', 'DELETE', 'ap_timeline', 'OLD.slug', 'OLD.id', 'deleted'),
887 zet('trg_feed_mn_ins', 'INSERT', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'new'),
888 zet('trg_feed_mn_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'updated'),
889 zet('trg_feed_mn_del', 'DELETE', 'ap_mentions', 'OLD.slug', 'OLD.object_uri', 'deleted'),
890 zet('trg_feed_ob_ins', 'INSERT', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'new'),
891 zet('trg_feed_ob_upd', 'UPDATE OF content, attachments', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'updated'),
892 zet('trg_feed_ob_del', 'DELETE', 'ap_outbox', 'OLD.site_slug', 'OLD.id', 'deleted'),
893 // ap_interactions draagt geen slug: die hangt aan de POST. Vandaar de join,
894 // en vandaar dat deze drie niet in de gewone vorm passen.
895 //
896 // De WHEN op kind='reply' is nodig omdat deze tabel ook likes en announces
897 // draagt, en die schrijven object_uri = '' (zie recordInteraction). Zonder de
898 // WHEN bumpte elke inkomende like de rev, werd elke wachter gewekt en kreeg
899 // die de hele collectie opnieuw terwijl er niets aan veranderd was: precies de
900 // kosten die de 304 moest wegnemen. Bovendien belandde er dan een rij op de
901 // lege string in ap_feed_state, die feedChangesSince vervolgens uitdeelt.
902 // De oude cursor filterde hier wel op kind; bij ap_timeline is dit ook gedaan
903 // (de UPDATE OF sluit liked/boosted uit) en รฉรฉn tabel verder vergeten.
904 zet('trg_feed_ia_ins', 'INSERT', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'new')} WHERE p.id = NEW.post_id`, "NEW.kind = 'reply'"),
905 zet('trg_feed_ia_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'updated')} WHERE p.id = NEW.post_id`, "NEW.kind = 'reply'"),
906 zet('trg_feed_ia_del', 'DELETE', 'ap_interactions', '', '', '', `${joinPosts('OLD.object_uri', 'deleted')} WHERE p.id = OLD.post_id`, "OLD.kind = 'reply'"),
907 ].join('\n'));
908 } catch (e) {
909 // Niet fataal: zonder deze tabel valt het wachten terug op "altijd de tijd
910 // volmaken", en dat is traag maar niet stuk.
911 console.error('โŒ feed-state triggers:', e.message);
912 }
913}
914
915function ensureColumn(table, column, definition) {
916 try {
917 db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
918 console.log(`๐Ÿ”ง Added column ${table}.${column}`);
919 } catch (e) {
920 // "duplicate column name" โ†’ already there. Anything else, surface it.
921 if (!/duplicate column/i.test(e.message)) {
922 console.error(`โŒ ensureColumn(${table}.${column}):`, e.message);
923 }
924 }
925}
926
927export default db;
Note: See TracBrowser for help on using the repository browser.