source: Klonkt/src/config/database.js@ 732272a

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

Luisteraars: een eigen tab, want het is een eigen soort relatie (shaer-0nh)

Accounts die aan de BIBLIOTHEEK hangen in plaats van aan de actor. Ze krijgen de
muziek en met opzet NIET de gewone posts -- wie zich op een platenkast
abonneert heeft niet om de Krant gevraagd.

EEN EIGEN TABEL EN GEEN VLAG, en dat is de kern. Zolang ze in
ap_library_followers staan kan een postbezorging ze niet per ongeluk meenemen.
Een vlag op ap_followers die iemand vergeet te filteren doet dat wel, en die
fout is aan onze kant onzichtbaar: de posts komen gewoon aan bij mensen die er
niet om vroegen. De vorm moet de fout onmogelijk maken, niet alleen
onwaarschijnlijk. Een test bewaakt dat ap_followers leeg blijft.

WAT ER WERKT

  • Follow op /ap/users/<slug>/library wordt herkend, meteen geaccepteerd en vastgelegd. Meteen, want de bibliotheek is openbaar (alles erin is fedi_open) -- er valt niets goed te keuren, en dan is wachten oneerlijk.
  • Undo(Follow) haalt hem er meteen weer uit.
  • Volgen is idempotent; remote servers sturen een Follow gerust nog eens.
  • inboxen() ontdubbelt op gedeelde inbox: twee luisteraars op dezelfde instance krijgen EEN bezorging.
  • de tab in Mediabeheer, met naam, handle, sinds en laatste bezorging, in drie talen.

DE HERKENNING IS STRENG. libraryOwnerSlug eist dat de uri met ONZE basis begint
en dat de site bestaat -- via localSlugOf, dezelfde zeef als gisteren. Anders
levert andermans /library met dezelfde padstaart hier een luisteraar op onze
naam op.

WAT ER NOG NIET IS, en dat is de volgende stap: de BEZORGING. Er gaat nog geen
Create(Audio) naar deze inboxen. De lijst vult zich dus wel en "laatste
bezorging" blijft leeg -- eerlijk, want er is niets bezorgd.

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

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