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

main
Last change on this file since af2cc73 was af2cc73, checked in by Bart <bart@โ€ฆ>, 3 weeks ago

Gastlogin via OpenWebAuth: een fan is een volger, geen accounthouder

fan_only betekende altijd al "mijn volgers op de fediverse", maar de poort vroeg
om een KLONKT-ACCOUNT. Dat is de verkeerde vraag, en hij sloot precies de mensen
buiten voor wie de poort openstond. Nu kan een bezoeker bij zijn EIGEN server
bewijzen dat hij @iemand@ergens is (FEP-61cf), en volgt hij deze site, dan is
hij binnen. Geen account hier, geen wachtwoord hier, geen cookie van een derde.

Wij zijn alleen de TARGET instance. Dat is de prettige helft: de home instance
heeft prive-sleutels nodig, wij alleen publieke. Er staat hier dus geen geheim
van iemand anders. De /magic-kant (Klonkt-gebruikers laten inloggen OP andere
sites) is bewust niet gebouwd -- andere functie.

De handtekening-verificatie is NIET opnieuw geschreven: AP.verifyRequest() doet
dit al voor de inbox, inclusief het vastpinnen van de sleutel op de herkomst van
de actor, een replay-venster en een verplichte digest. Een tweede implementatie
van "is deze aanvraag echt van wie hij zegt" is precies wat je niet wilt.

De drie aanvallen die de FEP noemt, hebben elk een toets:

  • IMPERSONATIE: ?zid= bepaalt niets, alleen het ingewisselde ?owt= telt. Mallory kan een link maken met zid=bob, maar komt terug met een token dat Mallory zegt.
  • OPEN REDIRECT: het ontdekte endpoint moet dezelfde host hebben als het adres dat de bezoeker intypte.
  • DoS: tokens vervallen in minuten, gaan na een keer gebruiken weg, en elke uitgifte veegt de oude op.

Onderweg gemeten en vastgelegd: PKCS#1 v1.5 GOOIT GEEN FOUT bij een verkeerde
sleutel. OpenSSL 3 doet aan implicit rejection en geeft afgeleide onzin terug,
juist zodat niemand aan het foutgedrag kan aflezen of zijn gok klopte. 200
vreemde sleutels: 0 fouten, 0 keer het token. De toets test dus "er komt iets
anders uit", niet "het knalt" -- anders schrijft de volgende lezer weer een
assert.throws die per ongeluk slaagt.

Webfinger op de eigen wortel wijst een home instance naar /owa/token. Alleen
origin + '/'; een ACTOR-uri met een pad blijft een 400, want dat legt
webfinger-bare-host.test.js vast en die keuze draai ik niet om als bijvangst.

De fanpoort toont nu het adresveld als hoofdweg en de lokale inlog als tweede,
en fgate.sub zegt niet langer "ingelogde vrienden" maar wat de poort werkelijk
vraagt.

1135 toetsen groen (was 1106). End-to-end nagelopen op een KOPIE van de
database: token inwisselen zet de sessie en haalt het token uit de URL, een
bewezen volger krijgt de tekst, een bewezen niet-volger krijgt de poort en geen
byte van de inhoud, en anoniem idem.

Co-Authored-By: Claude Opus 5 <claude@โ€ฆ>

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