source: Klonkt/src/config/database.js@ 32a4ffb

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

Onthoud de URI van antwoorden die we al ontvangen (shaer-e9g)

Antwoorden van accounts die je volgt komen gewoon binnen, ondertekend door de
schrijver zelf. belongsInTimeline houdt ze uit de Krant -- een antwoord hoort in
zijn draad, niet in de tijdlijn -- en daarna bewaarden we ze nergens. Kwam er
later een DOORGESTUURD antwoord op zo'n bericht, dan kende knownNoteUri de ouder
niet en werd het afgewezen. Terwijl we die ouder wel degelijk hadden gehad.

Op boiert ging daar 72% van het doorstuurverkeer op stuk: 18 unieke reacties
geweigerd tegen 7 geaccepteerd, in drie uur. Alle achttien waren echte antwoorden
in echte gesprekken, en de ontbrekende ouders waren stuk voor stuk geschreven
door accounts die boiert volgt.

Nieuwe tabel ap_seen_notes, alleen de URI. Geen inhoud, niets extra's op het
scherm, geen tweede administratie naast ap_timeline: dit beantwoordt uitsluitend
de vraag 'kennen wij dit bericht?'. Na 30 dagen gesnoeid, af en toe tijdens het
schrijven zodat een server die weken doorloopt ook opruimt -- doorsturen gebeurt
kort na het antwoord.

De grens zit in WIE er onthouden wordt, en dat is de hele veiligheidsvraag.
Alleen schrijvers die je zelf volgt. Zou het voor iedereen gelden, dan kan een
vreemde eerst een bericht neerleggen en daarna met een doorgestuurd antwoord
daarop de dereference naar een adres van zijn keuze sturen. Er wordt dus niets
van een onbekende aangenomen dat we niet al hadden.

Dit volgt op Barts keuze om gesprekken van anderen NIET te gaan volgen: dat
blijft zo. We stoppen alleen met weggooien wat we al kregen.

Gecontroleerd dat de tests bijten: haal de lookup uit knownNoteUri en geval 5
valt om, haal de volg-eis weg en geval 3. Suite 478/478.

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

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