source: Klonkt/src/config/database.js@ 54ee51c

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

Modules laden vanuit de shell in plaats van inline script (shaer-bqr, stap 1)

Het mechanisme uit optie C, met de bottom-tab als eerste geval zodat het ook
te bewijzen is.

WAAROM. De CSP-nonce rouleert per verzoek (shaer-0i6). Een script dat via htmx
binnenkomt draagt dus een nonce die het document niet kent en wordt geweigerd.
De chrome komt bij ELKE navigatie out-of-band opnieuw binnen, dus daar valt de
JS bij de eerste klik binnen de site al weg.

HOE. Een bootstrap in shell.ejs -- die komt alleen bij een volledige laadbeurt
binnen en heeft dus wel de goede nonce. Hij leest body[data-js], een lijst
modulenamen, en importeert ze uit /assets/js/mod/. Een dynamische import vanuit
een vertrouwd script is precies waar strict-dynamic voor bedoeld is, dus de
module zelf heeft geen nonce nodig.

Bij een htmx-navigatie zet de pcmsNav-trigger data-js opnieuw en haalt de
bootstrap op wat er nieuw bij staat. 'chrome' staat er altijd bij.

De naam wordt een PAD, dus hij moet door /[a-z0-9-]+$/ -- geen punt, geen
schuine streep.

EERSTE GEVAL: de zoekknop van de bottom-tab. Geen servergegevens erin, al
gedelegeerd, al voorzien van een slot -- dus de verhuizing verandert niets aan de
logica en het mechanisme is er echt mee te toetsen.

WAT DIT BLOOTLEGT VOOR DE VOLGENDE STAP: het topnav-script interpoleert
vertalingen (<%= t('search.section_posts') %>) en kan dus niet zomaar een
statisch bestand worden. Servergegevens horen via een data-attribuut naar een
module, niet via interpolatie in de code. Dat is een eigen stap en staat als
zodanig in mod/chrome.js opgeschreven.

Templates compileren, suite 551/551. Het echte bewijs is een klik BINNEN de site:
na een herlading werkt alles toch al.

  • Property mode set to 100644
File size: 47.0 KB
RevLine 
[7bc636b]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');
[7b07035]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
[7bc636b]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');
[72ec6a4]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.)
[5c373b8]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 )`);
[fa33214]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 )`);
[2b4252c]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 )`);
[1e172f3]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');
[7bc636b]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');
[834bcc3]122 // Google OAuth: link a Google account to a user (login via Google).
[c80e78b]123 ensureColumn('users', 'google_sub', 'TEXT');
[834bcc3]124 // Read-only/viewer account: can view everything but make no changes.
[640b39c]125 ensureColumn('users', 'readonly', 'INTEGER DEFAULT 0');
[834bcc3]126 // Personal interface language (nl|en|de). Null = follow the default (site/env/browser).
[5e61b17]127 ensureColumn('users', 'lang', 'TEXT');
[7bc636b]128 // Site-level moderation toggle. 'trust' = auto-approve, 'moderate' = pending until reviewed.
[834bcc3]129 // Circles: whether this site may appear in other sites' circles (surfacing opt-out).
[0091cb7]130 ensureColumn('sites', 'allow_circle', 'INTEGER DEFAULT 1');
[7bc636b]131
[834bcc3]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.
[7881080]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 }
[834bcc3]143 } catch (e) { /* sites table still empty/absent on fresh init — ensurePrimarySite handles it */ }
[7881080]144
[7bc636b]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}]
[8ea3d0d]157 ensureColumn('sites', 'feed_view_default', "TEXT DEFAULT 'grid'"); // timeline | grid
[7bc636b]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');
[fc40410]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');
[e27b8db]166 // The heavier sibling (FEP-633c 5.6): may a player from outside this app run
167 // INSIDE it? A preview is a picture; playback hands the screen to a third
168 // party's engine, recommendations and all. Two settings, so the guardians can
169 // allow the one without the other. NULL = auto, which means off for a ward.
170 ensureColumn('sites', 'external_playback', 'INTEGER');
[fc40410]171 ensureColumn('sites', 'og_theme', 'TEXT'); // OG share-card variant: NULL=auto (follow site theme) | 'light' | 'dark'
[ccaa530]172 // FEP-7628: former identities this actor claims (JSON array of actor URIs).
173 // Publishing them as alsoKnownAs is what lets the OLD server approve a Move
174 // of its followers to this account — the claim must be visible on OUR side.
175 ensureColumn('sites', 'ap_aliases', 'TEXT');
[0ca7e9a4]176 ensureColumn('sites', 'moved_to', 'TEXT'); // FEP-7628 slice 2: waarheen dit account vertrok
[7bc636b]177
178 // Per-post noindex + type
179 ensureColumn('posts', 'noindex', 'INTEGER DEFAULT 0');
[834bcc3]180 ensureColumn('posts', 'publish_at', 'DATETIME'); // release planning (premium #3): scheduled go-live
[b9dc94c]181 ensureColumn('posts', 'fan_only', 'INTEGER DEFAULT 0'); // fan-only preview (premium #3)
[837fc9c]182 ensureColumn('posts', 'nsfw', 'INTEGER DEFAULT 0'); // sensitive content → blur + click-to-reveal; fediverse sensitive
[1d6f9a2]183 ensureColumn('posts', 'cover_video_url', 'TEXT'); // muted loop MP4 for an animated cover (Safari-smooth)
[d18c60e]184 ensureColumn('posts', 'cover_alt', 'TEXT'); // alt text / description for the cover (a11y → AS2 attachment `name`)
[0688b5f]185 ensureColumn('posts', 'language', 'TEXT'); // BCP-47 content language → federates as AS2 contentMap (Mastodon language filter/translate)
[b7d4458]186 ensureColumn('posts', 'content_warning', 'TEXT'); // custom CW label (empty = default "Gevoelige inhoud")
[7bc636b]187 ensureColumn('posts', 'type', "TEXT DEFAULT 'post'"); // post | foto | video | audio
[0403187]188 ensureColumn('posts', 'poll_json', 'TEXT'); // a poll WE host → federates as AS2 Question: {multiple,options[{name}],endTime,closed}
[7bc636b]189
[834bcc3]190 // Statistics (premium module) — bare counters, cookie-free.
191 ensureColumn('posts', 'view_count', 'INTEGER DEFAULT 0'); // views per post
[d549549]192 ensureColumn('audio_tracks', 'play_count', 'INTEGER DEFAULT 0'); // plays per track
[834bcc3]193 ensureColumn('audio_tracks', 'downloadable', 'INTEGER DEFAULT 0'); // download-for-email (premium #2)
194 ensureColumn('audio_tracks', 'credit', 'TEXT'); // owner/credit (copyright holder)
195 ensureColumn('audio_tracks', 'license', 'TEXT'); // license (e.g. "CC BY 4.0", "All rights reserved")
196 ensureColumn('audio_tracks', 'link_spotify', 'TEXT'); // "open in" links per track
[183875b]197 ensureColumn('audio_tracks', 'link_youtube', 'TEXT');
198 ensureColumn('audio_tracks', 'link_soundcloud', 'TEXT');
[f2eacca]199 // Per-track: federate the actual audio file as an AS2 Audio attachment so it plays inline
200 // in EVERY fediverse client (incl. the Mastodon apps). Default 0 = gated (web player only,
201 // file not exposed). Opt-in 1 = the file is served ungated + shared on the fediverse.
202 ensureColumn('audio_tracks', 'fedi_open', 'INTEGER DEFAULT 0');
[d549549]203
[7bc636b]204 // Playlists (v9 feature) — first-class entity. CREATE IF NOT EXISTS is
205 // idempotent so it's safe to run on every boot regardless of DB age.
206 db.exec(`
207 CREATE TABLE IF NOT EXISTS playlists (
208 id TEXT PRIMARY KEY,
209 site_id TEXT NOT NULL,
210 title TEXT NOT NULL,
211 artist TEXT,
212 year INTEGER,
213 cover_url TEXT,
214 kind TEXT DEFAULT 'album',
215 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
216 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
217 FOREIGN KEY (site_id) REFERENCES sites(id)
218 );
219 CREATE TABLE IF NOT EXISTS playlist_tracks (
220 playlist_id TEXT NOT NULL,
221 track_id TEXT NOT NULL,
222 position INTEGER NOT NULL DEFAULT 0,
223 PRIMARY KEY (playlist_id, track_id),
224 FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
225 FOREIGN KEY (track_id) REFERENCES audio_tracks(id) ON DELETE CASCADE
226 );
227 CREATE INDEX IF NOT EXISTS idx_playlist_tracks_pos
228 ON playlist_tracks(playlist_id, position);
229 `);
[6351545]230
[834bcc3]231 // Global app settings (key/value singleton). Includes the tenancy mode
232 // (solo = one site, hub = company site + /user/). Default = solo.
[6351545]233 db.exec(`
234 CREATE TABLE IF NOT EXISTS app_settings (
235 key TEXT PRIMARY KEY,
236 value TEXT,
237 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
238 );
239 `);
240 db.prepare("INSERT OR IGNORE INTO app_settings (key, value) VALUES ('tenancy', 'solo')").run();
[b300682]241
[834bcc3]242 // ── Statistics (premium) — cookie-free ──────────────────────
243 // stat_daily: pageview count per day per site (bare counter).
244 // stat_visitor_day: one row per UNIQUE visitor hash per day per site
245 // (sha256 of IP+UA+day-salt; the salt rotates daily and is never stored
246 // → no persistent identifier, no cookie, no consent required).
[d549549]247 db.exec(`
248 CREATE TABLE IF NOT EXISTS stat_daily (
249 site_id TEXT NOT NULL,
250 day TEXT NOT NULL,
251 pageviews INTEGER NOT NULL DEFAULT 0,
252 PRIMARY KEY (site_id, day)
253 );
254 CREATE TABLE IF NOT EXISTS stat_visitor_day (
255 site_id TEXT NOT NULL,
256 day TEXT NOT NULL,
257 visitor_hash TEXT NOT NULL,
258 PRIMARY KEY (site_id, day, visitor_hash)
259 );
260 CREATE INDEX IF NOT EXISTS idx_stat_visitor_day ON stat_visitor_day(site_id, day);
[1794fac]261 CREATE TABLE IF NOT EXISTS stat_referrer (
262 site_id TEXT NOT NULL,
263 host TEXT NOT NULL,
264 count INTEGER NOT NULL DEFAULT 0,
265 PRIMARY KEY (site_id, host)
266 );
[d549549]267 `);
268
[834bcc3]269 // Newsletter / mailing list (premium). Subscribers per site; double opt-in when SMTP
270 // is configured (status 'pending' until confirmed), otherwise single opt-in ('confirmed').
271 // 'unsub' = unsubscribed. token = confirm/unsubscribe key (used in email links).
[2e247e4]272 db.exec(`
273 CREATE TABLE IF NOT EXISTS subscribers (
274 id TEXT PRIMARY KEY,
275 site_id TEXT NOT NULL,
276 email TEXT NOT NULL,
277 status TEXT NOT NULL DEFAULT 'pending',
278 source TEXT DEFAULT 'widget',
279 token TEXT NOT NULL,
280 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
281 confirmed_at DATETIME,
282 UNIQUE(site_id, email)
283 );
284 CREATE INDEX IF NOT EXISTS idx_subscribers_site_status ON subscribers(site_id, status);
285 `);
286
[834bcc3]287 // Sent newsletters (history + counts).
[2e247e4]288 db.exec(`
289 CREATE TABLE IF NOT EXISTS newsletters (
290 id TEXT PRIMARY KEY,
291 site_id TEXT NOT NULL,
292 subject TEXT NOT NULL,
293 body TEXT NOT NULL,
294 sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
295 recipient_count INTEGER DEFAULT 0
296 );
297 `);
[37edecd]298
[834bcc3]299 // Show agenda (premium #8): tour dates / gigs per site.
[8d32dcf]300 db.exec(`
301 CREATE TABLE IF NOT EXISTS shows (
302 id TEXT PRIMARY KEY,
303 site_id TEXT NOT NULL,
304 date TEXT NOT NULL,
305 time TEXT,
306 city TEXT NOT NULL,
307 venue TEXT,
308 country TEXT,
309 ticket_url TEXT,
310 notes TEXT,
311 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
312 );
313 CREATE INDEX IF NOT EXISTS idx_shows_site_date ON shows(site_id, date);
314 `);
315
[834bcc3]316 // Link-in-bio click statistics (premium #6). One counter per (site, url); the
317 // link-in-bio page links via /links/go/:i which counts the click and redirects.
[37edecd]318 db.exec(`
319 CREATE TABLE IF NOT EXISTS link_clicks (
320 site_id TEXT NOT NULL,
321 url TEXT NOT NULL,
322 clicks INTEGER DEFAULT 0,
323 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
324 PRIMARY KEY (site_id, url)
325 );
326 `);
[535f955]327
[6bd25d1]328
329 // ── ActivityPub (fediverse bridge) ──────────────────────────
330 // RSA keypair per actor (Mastodon-compatible HTTP Signatures; separate from
331 // the Cirkels Ed25519 keys). ap_followers = remote AP actors following us.
332 db.exec(`
333 CREATE TABLE IF NOT EXISTS ap_keys (
334 slug TEXT PRIMARY KEY,
335 public_pem TEXT NOT NULL,
336 private_pem TEXT NOT NULL,
337 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
338 );
339 CREATE TABLE IF NOT EXISTS ap_followers (
340 id INTEGER PRIMARY KEY AUTOINCREMENT,
341 slug TEXT NOT NULL,
342 actor_uri TEXT NOT NULL,
343 inbox TEXT,
344 shared_inbox TEXT,
345 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
346 UNIQUE(slug, actor_uri)
347 );
348 CREATE INDEX IF NOT EXISTS idx_ap_followers_slug ON ap_followers(slug);
[c16e0a5]349 CREATE TABLE IF NOT EXISTS ap_interactions (
350 id INTEGER PRIMARY KEY AUTOINCREMENT,
351 kind TEXT NOT NULL, -- 'reply' | 'like' | 'announce'
352 post_id TEXT NOT NULL,
353 object_uri TEXT NOT NULL DEFAULT '', -- remote note id (reply) or '' (like/announce)
354 actor_uri TEXT NOT NULL,
355 actor_name TEXT,
356 actor_handle TEXT,
357 actor_url TEXT,
358 actor_icon TEXT,
359 content TEXT, -- sanitized HTML (reply)
360 published TEXT,
[7d932ce]361 parent_uri TEXT, -- the note this reply replies to (for nesting)
[c16e0a5]362 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
363 UNIQUE(kind, post_id, actor_uri, object_uri)
364 );
365 CREATE INDEX IF NOT EXISTS idx_ap_inter_post ON ap_interactions(post_id, kind);
[67c1f24]366 -- Moderation tombstones: object URIs the site owner removed. Checked at ingest
367 -- (handleInbox) AND by the thread-crawler, so a removed reply never comes back
368 -- via thread-filling. Private notes can't be flagged via authorize_interaction
369 -- (their fetch 401s), so owner moderation acts on the locally stored copy.
370 CREATE TABLE IF NOT EXISTS ap_rejected_objects (
371 object_uri TEXT PRIMARY KEY,
372 post_id TEXT,
373 reason TEXT,
374 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
375 );
[d49b60b]376 -- ActivityPub C2S (client-to-server): OAuth 2.0 for native/web clients (Shaer).
377 -- Public clients + PKCE (RFC 8252); tokens stored hashed; token is per user+site.
378 CREATE TABLE IF NOT EXISTS oauth_clients (
379 client_id TEXT PRIMARY KEY,
380 client_name TEXT,
381 redirect_uris TEXT NOT NULL, -- JSON array
382 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
383 );
384 CREATE TABLE IF NOT EXISTS oauth_codes (
385 code TEXT PRIMARY KEY,
386 client_id TEXT NOT NULL,
387 user_id TEXT NOT NULL,
388 site_slug TEXT NOT NULL,
389 redirect_uri TEXT NOT NULL,
390 code_challenge TEXT, -- PKCE S256 (verplicht voor public clients)
391 scope TEXT,
392 expires_at DATETIME NOT NULL,
393 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
394 );
395 CREATE TABLE IF NOT EXISTS oauth_tokens (
396 token_hash TEXT PRIMARY KEY, -- sha256(bearer); het token zelf slaan we nooit op
397 client_id TEXT NOT NULL,
398 user_id TEXT NOT NULL,
399 site_slug TEXT NOT NULL,
400 scope TEXT,
401 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
402 last_used_at DATETIME
403 );
[61e3daf]404 -- Paid posts (klonkt-demo-aki): the site owner's own Patreon campaign.
405 -- Secrets are encrypted at rest (CryptoBox). Never reuses the instance-level
406 -- patreon_* settings, which are Klonkt Premium's separate license flow.
407 CREATE TABLE IF NOT EXISTS paid_patreon (
408 site_id TEXT PRIMARY KEY,
409 client_id TEXT,
410 client_secret_enc TEXT,
411 campaign_id TEXT,
412 access_token_enc TEXT,
413 refresh_token_enc TEXT,
414 token_exp INTEGER, -- unix seconds
415 default_min_cents INTEGER DEFAULT 0,
416 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
417 );
[9e9e6f9]418 -- One row per passkey. NO patron identity is stored (design decision):
419 -- {passkey, site, proven cents, expiry}. Not traceable to a person.
420 CREATE TABLE IF NOT EXISTS paid_entitlements (
421 credential_id TEXT PRIMARY KEY, -- WebAuthn credential id (opaque, base64url)
422 site_id TEXT NOT NULL,
423 public_key TEXT NOT NULL, -- COSE public key, base64url
424 counter INTEGER DEFAULT 0,
425 transports TEXT,
426 min_cents INTEGER DEFAULT 0, -- the amount proven at link time
427 expires_at INTEGER NOT NULL, -- unix seconds; re-link after
428 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
429 );
[ad10715]430 -- Web Push (docs/webpush-design.md): one row per browser/device the owner
431 -- enabled notifications on. Payloads are encrypted to p256dh/auth (RFC 8291).
432 CREATE TABLE IF NOT EXISTS push_subscriptions (
433 endpoint TEXT PRIMARY KEY, -- push-service URL for this device
434 user_id TEXT NOT NULL,
435 p256dh TEXT NOT NULL, -- client public key
436 auth TEXT NOT NULL, -- client auth secret
437 alert_types TEXT, -- JSON {follow,reply,like,boost,dm}
438 ua_label TEXT,
439 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
440 last_ok_at DATETIME
441 );
[55bc7f9]442 CREATE TABLE IF NOT EXISTS ap_outbox (
443 id TEXT PRIMARY KEY, -- note path segment (uuid) → /ap/notes/<id>
444 site_slug TEXT NOT NULL,
445 post_id TEXT NOT NULL,
446 post_slug TEXT,
447 in_reply_to TEXT, -- remote status uri we reply to
448 to_actor TEXT, -- remote actor uri (mentioned)
449 to_handle TEXT,
450 content TEXT NOT NULL, -- sanitized HTML of our reply
451 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
452 );
453 CREATE INDEX IF NOT EXISTS idx_ap_outbox_post ON ap_outbox(post_id);
[3d37c67]454 -- Your like/boost state on a REMOTE post (the interact page), so those become toggles.
455 CREATE TABLE IF NOT EXISTS ap_my_reactions (
456 site_slug TEXT NOT NULL,
457 target_uri TEXT NOT NULL,
458 kind TEXT NOT NULL, -- 'like' | 'boost'
459 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
460 UNIQUE(site_slug, target_uri, kind)
461 );
[6bd25d1]462 `);
[7d932ce]463 ensureColumn('ap_interactions', 'parent_uri', 'TEXT'); // nesting (existing DBs)
[c745659]464 ensureColumn('ap_interactions', 'acted_boost', 'INTEGER DEFAULT 0'); // owner boosted this comment (🔁) → can undo
[3289a64]465 ensureColumn('ap_interactions', 'acted_like', 'INTEGER DEFAULT 0'); // owner liked this comment (⭐) → can undo
[914eb9f]466
467 // Fediverse CLIENT: accounts WE follow (outbound) + the home timeline of their posts.
468 db.exec(`
469 CREATE TABLE IF NOT EXISTS ap_following (
470 id INTEGER PRIMARY KEY AUTOINCREMENT,
471 slug TEXT NOT NULL, -- our site that follows
472 actor_uri TEXT NOT NULL, -- the followed account's actor id
473 handle TEXT, name TEXT, icon TEXT, url TEXT,
474 inbox TEXT, -- their inbox (for Create delivery / Undo)
475 follow_id TEXT, -- the Follow activity id we sent (Accept matching)
476 status TEXT DEFAULT 'pending', -- pending | accepted
477 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
478 UNIQUE(slug, actor_uri)
479 );
[32a4ffb]480 -- Antwoorden van accounts die we volgen komen gewoon binnen, ondertekend
481 -- door de schrijver, maar horen niet in de Krant (belongsInTimeline) en
482 -- werden daarna nergens bewaard. Kwam er later een doorgestuurd antwoord OP
483 -- zo'n bericht, dan kenden we de ouder niet en wezen we het af (shaer-e9g).
484 -- Alleen de URI, geen inhoud: dit voedt uitsluitend de vraag "kennen wij dit
485 -- bericht?". Wordt na 30 dagen gesnoeid; doorsturen gebeurt kort na het
486 -- antwoord, dus langer bewaren levert niets op.
487 CREATE TABLE IF NOT EXISTS ap_seen_notes (
488 uri TEXT PRIMARY KEY,
489 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
490 );
491 CREATE INDEX IF NOT EXISTS idx_ap_seen_notes_age ON ap_seen_notes(created_at);
[914eb9f]492 CREATE TABLE IF NOT EXISTS ap_timeline (
493 id TEXT NOT NULL, -- the remote note's AP id
494 slug TEXT NOT NULL, -- whose home timeline (our site)
495 author_uri TEXT, author_name TEXT, author_handle TEXT, author_icon TEXT, author_url TEXT,
496 content TEXT, url TEXT, published TEXT, media_json TEXT,
497 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
498 UNIQUE(slug, id)
499 );
500 CREATE INDEX IF NOT EXISTS idx_ap_timeline_slug ON ap_timeline(slug, published);
[f5c3870]501 CREATE TABLE IF NOT EXISTS ap_blocks (
502 id INTEGER PRIMARY KEY AUTOINCREMENT,
503 slug TEXT NOT NULL, -- our site that set the block
504 target TEXT NOT NULL, -- actor URI (actor block) or domain (domain block)
505 kind TEXT NOT NULL, -- 'actor' | 'domain'
506 label TEXT, -- display (@handle or domain)
507 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
508 UNIQUE(slug, target)
509 );
510 CREATE INDEX IF NOT EXISTS idx_ap_blocks_target ON ap_blocks(target);
[780a7c6]511 -- Committed guardian ↔ ward relations, one row per local side. role
512 -- 'ward' = the local slug is a ward of other_uri; 'guardian' = the local
513 -- slug guards other_uri. status is always 'accepted' here now: PENDING
514 -- offers live in ap_guardian_offers below (FEP-633c multi-party handshake).
[6b5d7da]515 CREATE TABLE IF NOT EXISTS ap_guardianships (
516 id INTEGER PRIMARY KEY AUTOINCREMENT,
517 slug TEXT NOT NULL, -- our local site in this relation (guardianship module)
518 role TEXT NOT NULL, -- 'guardian' (slug guards other) | 'ward' (other guards slug)
519 other_uri TEXT NOT NULL, -- the counterpart actor URI (local or remote)
520 other_handle TEXT, -- cached @user@host for display
[780a7c6]521 status TEXT NOT NULL, -- 'offered' (legacy) | 'accepted'
[6b5d7da]522 offer_id TEXT, -- the Offer activity id (FEP-633c section 3)
523 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
524 UNIQUE(slug, role, other_uri)
525 );
526 CREATE INDEX IF NOT EXISTS idx_ap_guardianships_slug ON ap_guardianships(slug, role, status);
[780a7c6]527 -- The multi-party handshake (FEP-633c section 3), one row per offer this
528 -- instance is a party to. Mirrors the Shaer test daemon's Handshake:
529 -- accepts accumulate in ap_guardian_offer_accepts, and the offer commits
530 -- only when the candidate returns the handle after ward + candidate + at
531 -- least one existing guardian have accepted.
532 CREATE TABLE IF NOT EXISTS ap_guardian_offers (
533 offer_id TEXT NOT NULL, -- the Offer activity id (minted by the candidate)
534 slug TEXT NOT NULL, -- the local site tracking this handshake (each party keeps its own copy)
535 ward_uri TEXT NOT NULL, -- the ward-to-be
536 candidate_uri TEXT NOT NULL, -- the guardian-candidate (fixed initiator)
537 existing_guardians TEXT NOT NULL DEFAULT '[]', -- JSON array of the ward's current guardian URIs
538 status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'committed' | 'void'
539 handle TEXT, -- the escalation handle returned at commit (section 6)
540 ward_handle TEXT, -- cached @ward@host for display
541 candidate_handle TEXT, -- cached @candidate@host for display
542 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
543 PRIMARY KEY (slug, offer_id)
544 );
545 CREATE INDEX IF NOT EXISTS idx_ap_guardian_offers_slug ON ap_guardian_offers(slug, status);
546 CREATE TABLE IF NOT EXISTS ap_guardian_offer_accepts (
547 offer_id TEXT NOT NULL, -- FK to ap_guardian_offers
548 slug TEXT NOT NULL, -- the local site's copy of the tally
549 party_uri TEXT NOT NULL, -- the party who accepted (ward | candidate | an existing guardian)
550 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
551 PRIMARY KEY (slug, offer_id, party_uri)
552 );
[65abc85]553 -- FEP-633c §5.6: a gated setting a ward's guardians decide together, which
554 -- has to work when they live on other servers (the ordinary case). One row
555 -- per guardian answer; the ward's server tallies (§3.5) and enforces.
556 -- The proposals themselves, so an Accept that only references the offer
557 -- id can still be resolved to "which feature, which value".
558 CREATE TABLE IF NOT EXISTS ap_gated_offers (
559 offer_id TEXT PRIMARY KEY,
560 slug TEXT NOT NULL, -- the ward, on this server
561 feature TEXT NOT NULL,
562 value INTEGER NOT NULL,
563 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
564 );
[88d7c8f]565 -- The guardian-side COPY of a gated-setting proposal on a ward, forwarded
566 -- here by the WARD's server (the same shape ap_follow_reviews has for a
567 -- gated follow). Without it a guardian on another server never learns a
568 -- proposal exists and can never answer it, so a threshold of two can never
569 -- be reached and every proposal expires. The answer goes back to the
570 -- ward's inbox, which tallies (5.6).
571 CREATE TABLE IF NOT EXISTS ap_gated_reviews (
572 id TEXT NOT NULL, -- the offer id, as minted by the proposer
573 guardian_slug TEXT NOT NULL, -- us, one of the ward's guardians
574 ward_uri TEXT NOT NULL,
575 ward_inbox TEXT,
576 proposer TEXT, -- who opened it (for display)
577 feature TEXT NOT NULL,
578 value INTEGER NOT NULL,
579 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
580 PRIMARY KEY (guardian_slug, id)
581 );
[d56d471]582 -- The PROPOSER's own record of a gated proposal it sent (5.6). Without it
583 -- a guardian clicks "propose", the ward's server tallies somewhere else,
584 -- and the proposer has nowhere to even see that something is running: the
585 -- status was a button caption that did not survive a page refresh. The
586 -- ward's server answers the Offer once the decision settles (Accept when
587 -- it settled on the proposed value, Reject otherwise); that answer lands
588 -- in status. An open row past the decision window renders as expired.
589 CREATE TABLE IF NOT EXISTS ap_gated_sent (
590 offer_id TEXT PRIMARY KEY, -- as minted by us, the proposer
591 guardian_slug TEXT NOT NULL, -- us
592 ward_uri TEXT NOT NULL,
593 feature TEXT NOT NULL,
594 value INTEGER NOT NULL, -- what we proposed
595 status TEXT NOT NULL DEFAULT 'open', -- 'open' | 'accepted' | 'rejected'
596 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
597 );
[65abc85]598 CREATE TABLE IF NOT EXISTS ap_gated_votes (
599 slug TEXT NOT NULL, -- the WARD, on this server
600 feature TEXT NOT NULL, -- e.g. 'shaer:externalEmbeds'
601 guardian_uri TEXT NOT NULL, -- who answered (must be a committed guardian)
602 value INTEGER NOT NULL, -- the value they voted for (0/1)
603 opened_at DATETIME NOT NULL, -- when this decision opened (the window start)
604 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
605 PRIMARY KEY (slug, feature, guardian_uri)
606 );
[6eab7e9]607 -- Guardian availability (FEP-633c 3.6): one guardian's attention as seen
608 -- from one ward on this server. Never public; the ward reads it via the
609 -- owner-only guardians queue. One rule above all: one answer restores
610 -- everything, so every row here is one answer away from disappearing.
611 CREATE TABLE IF NOT EXISTS ap_guardian_attention (
612 ward_slug TEXT NOT NULL,
613 guardian_uri TEXT NOT NULL,
614 state TEXT NOT NULL DEFAULT 'active', -- 'active' | 'away' | 'dormant'
615 away_until INTEGER, -- epoch ms while declared away
616 PRIMARY KEY (ward_slug, guardian_uri)
617 );
618 -- The ONLY admissible dormancy evidence (3.6.2): directly addressed
619 -- requests that went unanswered. Calendar time alone never counts.
620 CREATE TABLE IF NOT EXISTS ap_attention_requests (
621 ward_slug TEXT NOT NULL,
622 guardian_uri TEXT NOT NULL,
623 request_id TEXT NOT NULL,
624 asked_at INTEGER NOT NULL, -- epoch ms
625 PRIMARY KEY (ward_slug, guardian_uri, request_id)
626 );
627 -- A lapse (3.6.3): the available co-guardians deciding to release a
628 -- dormant one. Irreversible, so the window always runs in full; any sign
629 -- of life from the target cancels it outright.
630 CREATE TABLE IF NOT EXISTS ap_lapses (
631 id TEXT PRIMARY KEY,
632 ward_slug TEXT NOT NULL,
633 ward_uri TEXT NOT NULL,
634 target_uri TEXT NOT NULL,
635 opened_by TEXT NOT NULL,
636 set_json TEXT NOT NULL, -- the available set at open, target excluded
637 accepts_json TEXT NOT NULL DEFAULT '[]',
638 rejects_json TEXT NOT NULL DEFAULT '[]',
639 opened_at INTEGER NOT NULL, -- epoch ms
640 window_ms INTEGER NOT NULL,
641 cancelled INTEGER NOT NULL DEFAULT 0,
642 applied INTEGER NOT NULL DEFAULT 0,
643 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
644 );
[5a6a457]645 CREATE TABLE IF NOT EXISTS ap_delivery (
646 id INTEGER PRIMARY KEY AUTOINCREMENT,
647 slug TEXT NOT NULL, -- our site/actor that signs the delivery
648 inbox TEXT NOT NULL, -- recipient inbox URL
649 body TEXT NOT NULL, -- the activity JSON to POST
650 attempts INTEGER NOT NULL DEFAULT 0,
651 next_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
652 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
653 );
654 CREATE INDEX IF NOT EXISTS idx_ap_delivery_due ON ap_delivery(next_at);
[0403187]655 CREATE TABLE IF NOT EXISTS poll_votes (
656 id INTEGER PRIMARY KEY AUTOINCREMENT,
657 post_id INTEGER NOT NULL, -- our local poll post (posts.id)
658 actor_uri TEXT NOT NULL, -- the remote voter's AP actor URI
659 choice TEXT NOT NULL, -- the chosen option's name (matches poll_json options[].name)
660 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
661 UNIQUE(post_id, actor_uri, choice)
662 );
663 CREATE INDEX IF NOT EXISTS idx_poll_votes_post ON poll_votes(post_id);
[fe97cc3]664 CREATE TABLE IF NOT EXISTS ap_mentions (
665 id INTEGER PRIMARY KEY AUTOINCREMENT,
666 slug TEXT NOT NULL, -- our mentioned site/actor
667 object_uri TEXT NOT NULL, -- the remote note that mentions us
668 note_url TEXT, -- its human URL (open/interact)
669 actor_uri TEXT, actor_name TEXT, actor_handle TEXT, actor_icon TEXT, actor_url TEXT,
670 content TEXT, -- sanitized HTML snippet of the mentioning note
671 published TEXT,
672 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
673 UNIQUE(slug, object_uri)
674 );
675 CREATE INDEX IF NOT EXISTS idx_ap_mentions_slug ON ap_mentions(slug, created_at);
[737ea05]676 CREATE TABLE IF NOT EXISTS ap_reports (
677 id INTEGER PRIMARY KEY AUTOINCREMENT,
678 slug TEXT NOT NULL, -- our site the report is about (its owner moderates)
679 actor_uri TEXT, -- the reporter's actor URI
680 actor_name TEXT, actor_handle TEXT, actor_icon TEXT,
681 content TEXT, -- the reason (plain text)
682 objects TEXT, -- JSON array of reported object URIs (our actor + statuses)
683 seen INTEGER DEFAULT 0,
684 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
685 );
686 CREATE INDEX IF NOT EXISTS idx_ap_reports_slug ON ap_reports(slug, created_at);
[914eb9f]687 `);
[5045c30]688 // "Feature" a followed account: its posts show in the local Cirkel.
[f278df9]689 ensureColumn('ap_following', 'auto_boost', 'INTEGER DEFAULT 0');
[5045c30]690 // A timeline post you boosted (🔁) — also shown in the Cirkel (mixed by date).
691 ensureColumn('ap_timeline', 'boosted', 'INTEGER DEFAULT 0');
[9d34855]692 ensureColumn('ap_timeline', 'liked', 'INTEGER DEFAULT 0'); // a feed post you liked (⭐) → toggle
[b7d4458]693 ensureColumn('ap_timeline', 'nsfw', 'INTEGER DEFAULT 0'); // remote sensitive post → blur in the Cirkel
694 ensureColumn('ap_timeline', 'cw', 'TEXT'); // remote content-warning text
[97bacf2]695 ensureColumn('ap_timeline', 'emoji_json', 'TEXT'); // FEP-9098 custom emoji Emoji tags from the inbound note, served back as `tag`
[eb36688]696 ensureColumn('ap_timeline', 'link_json', 'TEXT'); // FEP-e232 object-link (quote/ref) tags from the inbound note, served back as `tag`
[6fd0e20]697 ensureColumn('ap_timeline', 'quote_json', 'TEXT'); // FEP-044f resolved quoted-post snapshot (author + content), for the embedded quote card
[b258a79]698 // FEP-044f: the fediverse object THIS post quotes, resolved once at publish
699 // time so buildNote (sync, also used by the outbox) needs no network.
700 ensureColumn('posts', 'quote_uri', 'TEXT'); // the quoted object's id
701 ensureColumn('posts', 'quote_actor', 'TEXT'); // its author, so we can address them
702 ensureColumn('ap_timeline', 'embed_json', 'TEXT'); // resolved EXTERNAL embed (oEmbed/provider), thumbnail-only; gated per site (sites.external_embeds)
[a677616]703 ensureColumn('ap_timeline', 'author_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the author's display name (shaer:author.emojis)
[f3caf19]704 ensureColumn('ap_timeline', 'reblog_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the booster's display name (shaer:booster.emojis)
[c6cdce6]705 ensureColumn('ap_timeline', 'reblog_name', 'TEXT'); // a followed account boosted this → "X boosted"
706 ensureColumn('ap_timeline', 'reblog_handle', 'TEXT'); // the booster's @handle
707 ensureColumn('ap_timeline', 'reblog_icon', 'TEXT'); // the booster's avatar
[6053c6c]708 ensureColumn('ap_timeline', 'poll_json', 'TEXT'); // a Question (poll): {multiple,options[{name,count}],endTime,closed,voters,voted}
[8878814]709
710 // Delivery health per follower → surface dead accounts for manual cleanup.
711 ensureColumn('ap_followers', 'last_delivery_at', 'DATETIME'); // last SUCCESSFUL delivery to this follower's inbox
712 ensureColumn('ap_followers', 'last_error_at', 'DATETIME'); // last time a delivery to it gave up (max retries)
[2d6a9c3]713
714 // ActivityPub `source` model: content_rendered = baked display HTML (#hashtags / URLs /
715 // @mentions linkified once at save). `content` stays the raw source used for editing and
716 // re-rendering. NULL on old posts → the render route bakes on the fly as a fallback.
717 ensureColumn('posts', 'content_rendered', 'TEXT');
[3778ddb]718
719 // AP addressing of an incoming interaction: 'public' | 'unlisted' | 'followers' | 'direct',
720 // derived from the note's to/cc at ingest. The public post page only renders public/unlisted
721 // replies; followers/direct replies surface in notifications (and later Messages) with post
722 // context instead. Existing rows default to 'public' (historically almost all were).
723 ensureColumn('ap_interactions', 'visibility', "TEXT DEFAULT 'public'");
[ef1853c]724 ensureColumn('ap_interactions', 'emoji_json', 'TEXT'); // FEP-9098 custom emojis in a reply's content (messages + thread)
725 ensureColumn('ap_interactions', 'actor_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the reply author's display name
[33e1dbd]726 // Rich replies: the reply's language (BCP47 code) → contentMap on the outgoing Note.
727 ensureColumn('ap_outbox', 'language', 'TEXT');
[feced2c]728 // Rich replies: JSON array [{url, mediaType, name}] → `attachment` on the Note.
729 ensureColumn('ap_outbox', 'attachments', 'TEXT');
[81b2e1e]730 ensureColumn('posts', 'ap_visibility', 'TEXT'); // public|quiet|friends|direct (C2S addressing, shaer-60b)
[928d1c7]731 ensureColumn('posts', 'paid', 'INTEGER DEFAULT 0'); // paid post (klonkt-demo-aki)
732 ensureColumn('posts', 'paid_min_cents', 'INTEGER'); // required support; null = owner default
[c3d12a6]733 ensureColumn('paid_patreon', 'patreon_url', 'TEXT'); // owner's public Patreon page → "Word supporter" link (klonkt-demo-aki)
[024f4f8]734 ensureColumn('ap_outbox', 'visibility', 'TEXT'); // 'direct' = private mention, never Public (shaer-tqc)
735 ensureColumn('ap_outbox', 'to_actors', 'TEXT'); // JSON array of recipient actor URIs for direct notes
[155c24e]736 ensureColumn('ap_outbox', 'help_request', 'INTEGER'); // FEP-633c shaer:helpRequest (ward's call for help)
[6b5d7da]737 ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
[e62f65d]738 ensureColumn('ap_outbox', 'wave', 'INTEGER'); // FEP-633c shaer:wave (guardian -> ward nudge)
[6eab7e9]739 ensureColumn('ap_outbox', 'away_until', 'INTEGER'); // FEP-633c 3.6.1 shaer:away + endTime (epoch ms)
[d56d471]740 ensureColumn('ap_gated_offers', 'proposer', 'TEXT'); // who proposed (5.6): the settle-answer goes back to them
[fa33214]741 // Did a guardian actually say yes to this follower? That is what makes the
742 // mutual shortcut sound: a ward may follow back anyone its guardians already
743 // admitted, without asking the same question twice. Only follows that came
744 // through the §5.3 gate carry the mark; a free actor's followers never faced
745 // one. Everyone already following when this column arrives is grandfathered
746 // in (Barts besluit, 3-8): the rule is exact from that moment forward rather
747 // than retroactively suspicious of relationships that already exist.
748 {
749 const had = db.prepare("SELECT COUNT(*) AS n FROM pragma_table_info('ap_followers') WHERE name = 'gate_approved'").get();
750 ensureColumn('ap_followers', 'gate_approved', 'INTEGER DEFAULT 0');
751 if (!had || !had.n) {
752 try { db.prepare('UPDATE ap_followers SET gate_approved = 1').run(); } catch { /* table still empty on a fresh init */ }
753 }
754 }
[6089c53]755 ensureColumn('posts', 'c2s_attachments', 'TEXT'); // media a C2S Note carried (JSON [{url,mediaType,name}]); buildNote federates them
[2a10445]756 // 30-7: C2S posts briefly got their content media copied onto the cover,
757 // which showed the same video twice on the post page. Clear the covers that
758 // duplicate their own content; idempotent, only ever touches those.
759 try {
760 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();
761 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();
762 } catch { /* posts table absent on fresh init */ }
[e62f65d]763 ensureColumn('ap_mentions', 'wave', 'INTEGER'); // inbound guardian wave
[af5b79b]764 // FEP-633c §2.2: object hint that the author is a ward. Register-only for now;
765 // used later at reddings-boei / escalation routing.
766 ensureColumn('ap_timeline', 'has_guardians', 'INTEGER');
767 ensureColumn('ap_mentions', 'has_guardians', 'INTEGER');
[d9ad6c5]768 // Berichten and de Krant render a post the same way, so a mention or a reply
769 // needs the same trimmings a timeline row already has: custom emojis, the
770 // media the note carried, and the quote / link-preview card.
771 ensureColumn('ap_mentions', 'emoji_json', 'TEXT'); // FEP-9098, in the content
772 ensureColumn('ap_mentions', 'actor_emoji_json', 'TEXT'); // FEP-9098, in the display name
773 ensureColumn('ap_mentions', 'media_json', 'TEXT');
774 ensureColumn('ap_mentions', 'quote_json', 'TEXT'); // FEP-044f quoted post
775 ensureColumn('ap_mentions', 'embed_json', 'TEXT'); // external link preview
776 ensureColumn('ap_interactions', 'media_json', 'TEXT');
777 ensureColumn('ap_interactions', 'quote_json', 'TEXT');
778 ensureColumn('ap_interactions', 'embed_json', 'TEXT');
[7922694]779 ensureColumn('ap_followers', 'name', 'TEXT'); // cached display name (shaer-aa3)
780 ensureColumn('ap_followers', 'handle', 'TEXT'); // @user@host
781 ensureColumn('ap_followers', 'icon', 'TEXT'); // avatar URL
[d14bf1c]782 feedStateTriggers();
783}
784
785/**
786 * Wat er met een tijdlijn gebeurd is, op één plek (shaer-n05).
787 *
788 * De inbox-lezing voegt vier bronnen samen. De vraag "is er iets veranderd" werd
789 * eerst beantwoord met MAX(rowid) over die vier -- een TOEVALLIGE eigenschap van
790 * de tabellen, geen feit dat ergens is opgeschreven. Dat gaf precies de gebreken
791 * die je van zo'n afleiding verwacht: bewerkingen en verwijderingen bewogen hem
792 * niet, en hij kon achteruit lopen. Dezelfde fout als reacties uitlezen uit
793 * ap_timeline.liked (shaer-9e9).
794 *
795 * Nu één rij per bericht per tijdlijn, met een oplopende `rev` en `kind`. Dat
796 * beantwoordt drie vragen die anders drie eigen oplossingen zouden krijgen:
797 * is er iets veranderd sinds N, wát is er veranderd, en is dit bericht bewerkt.
798 *
799 * Bijgehouden door TRIGGERS en niet door de aanroepende code, om dezelfde reden
800 * dat er geen gebeurtenis-emitter is: een trigger zit in de database, dus geen
801 * enkel codepad kan hem vergeten. De prijs is onzichtbare logica -- wie alleen de
802 * JavaScript leest ziet niet waarom deze tabel vult. Vandaar dat ze hier staan,
803 * bij de tabel, en niet verspreid.
804 *
805 * Let op de `UPDATE OF`-kolomlijsten: die zijn niet decoratief. Een like schrijft
806 * ap_timeline.liked en een 🔁 schrijft .boosted; zonder die afbakening zou je
807 * eigen like het bericht als BEWERKT merken en elke wachtende client wekken.
808 */
809function feedStateTriggers() {
810 try {
811 db.exec(`
812 CREATE TABLE IF NOT EXISTS ap_feed_state (
813 slug TEXT NOT NULL,
814 object_uri TEXT NOT NULL,
815 rev INTEGER NOT NULL,
816 kind TEXT NOT NULL, -- new | updated | deleted
817 at DATETIME DEFAULT CURRENT_TIMESTAMP,
818 PRIMARY KEY (slug, object_uri)
819 );
820 CREATE INDEX IF NOT EXISTS idx_ap_feed_state_rev ON ap_feed_state(slug, rev);
821 -- Eén doorlopende teller voor de hele instance. Bewust niet MAX(rev) uit de
822 -- tabel zelf: verdwijnt de hoogste rij, dan zou die teruglopen en denkt een
823 -- client dat er niets gebeurd is.
824 CREATE TABLE IF NOT EXISTS ap_feed_rev (n INTEGER NOT NULL);
825 `);
826 if (!db.prepare('SELECT COUNT(*) AS n FROM ap_feed_rev').get().n) {
827 db.prepare('INSERT INTO ap_feed_rev (n) VALUES (0)').run();
828 }
829 // slug + object_uri verschillen per bron; de rest is voor alle vier gelijk.
830 const zet = (naam, gebeurtenis, tabel, slug, uri, kind, extra = '') => `
831 DROP TRIGGER IF EXISTS ${naam};
832 CREATE TRIGGER ${naam} AFTER ${gebeurtenis} ON ${tabel} BEGIN
833 UPDATE ap_feed_rev SET n = n + 1;
834 INSERT INTO ap_feed_state (slug, object_uri, rev, kind)
835 ${extra || `VALUES (${slug}, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}')`}
836 ON CONFLICT(slug, object_uri) DO UPDATE
837 SET rev = excluded.rev, kind = excluded.kind, at = CURRENT_TIMESTAMP;
838 END;`;
839 const joinPosts = (uri, kind) => `
840 SELECT s.slug, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}'
841 FROM posts p JOIN sites s ON s.id = p.site_id`;
842 db.exec([
843 zet('trg_feed_tl_ins', 'INSERT', 'ap_timeline', 'NEW.slug', 'NEW.id', 'new'),
844 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'),
845 zet('trg_feed_tl_del', 'DELETE', 'ap_timeline', 'OLD.slug', 'OLD.id', 'deleted'),
846 zet('trg_feed_mn_ins', 'INSERT', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'new'),
847 zet('trg_feed_mn_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'updated'),
848 zet('trg_feed_mn_del', 'DELETE', 'ap_mentions', 'OLD.slug', 'OLD.object_uri', 'deleted'),
849 zet('trg_feed_ob_ins', 'INSERT', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'new'),
850 zet('trg_feed_ob_upd', 'UPDATE OF content, attachments', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'updated'),
851 zet('trg_feed_ob_del', 'DELETE', 'ap_outbox', 'OLD.site_slug', 'OLD.id', 'deleted'),
852 // ap_interactions draagt geen slug: die hangt aan de POST. Vandaar de join,
853 // en vandaar dat deze drie niet in de gewone vorm passen.
854 zet('trg_feed_ia_ins', 'INSERT', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'new')} WHERE p.id = NEW.post_id`),
855 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`),
856 zet('trg_feed_ia_del', 'DELETE', 'ap_interactions', '', '', '', `${joinPosts('OLD.object_uri', 'deleted')} WHERE p.id = OLD.post_id`),
857 ].join('\n'));
858 } catch (e) {
859 // Niet fataal: zonder deze tabel valt het wachten terug op "altijd de tijd
860 // volmaken", en dat is traag maar niet stuk.
861 console.error('❌ feed-state triggers:', e.message);
862 }
[7bc636b]863}
864
865function ensureColumn(table, column, definition) {
866 try {
867 db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
868 console.log(`🔧 Added column ${table}.${column}`);
869 } catch (e) {
870 // "duplicate column name" → already there. Anything else, surface it.
871 if (!/duplicate column/i.test(e.message)) {
872 console.error(`❌ ensureColumn(${table}.${column}):`, e.message);
873 }
874 }
875}
876
877export default db;
Note: See TracBrowser for help on using the repository browser.