source: Klonkt/src/config/database.js@ 7e9d0ea

main
Last change on this file since 7e9d0ea was 7e9d0ea, checked in by Robin <roboburr@โ€ฆ>, 4 weeks ago

De laatste tenancy-resten, en een testbestand dat alleen zijn naam kwijt was (shaer-x7c0)

Het meeste van de bead was al gedaan door 72ec6a4 (Hub-modus en guardian-lite
eruit), dezelfde dag nog. Wat er lag:

  • database.js beschreef nog een tenancy-modus met hub, en zette bij elke boot een app_settings-rij 'tenancy' die niemand meer leest. Beide weg. Bestaande rijen blijven staan; die opruimen is een aparte beslissing.
  • ensurePrimarySite.js noemde solo/hub/circle op twee plekken.

En het belangrijkste, precies wat de bead verkeerd had: hub-permissions.test.js
test GEEN verwijderde modus. Het dekt canAdminSite (inclusief de site_members-tak
die ooit stil kapot was), canEditPost en getPrimarySite -- allemaal springlevend,
en CLAUDE.md wijst dit bestand aan als de vangrail daarvoor. Weggooien had die
dekking gesloopt. Alleen de naam verwees nog naar de hub, dus die is aangepast,
met een kop die uitlegt waarom het bestand blijft.

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