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

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

Account-verhuizingen: inkomende Move plus alsoKnownAs-aliassen (FEP-7628)

De ontvangende helft van accountverhuizingen, in twee delen die samen een
verhuizing NAAR Klonkt mogelijk maken (shaer-0j2, slice 1).

Deel 1: een account dat onze sites volgen verhuist. Op een geldige Move volgt
elke lokale volger automatisch mee naar het nieuwe adres, met behoud van de
eigen auto-boost-keuze. Geldigheid heeft twee onafhankelijke benen en beide
zijn verplicht: de ondertekenaar is zelf partij in de verhuizing (oude actor
in push-modus of nieuwe actor in pull-modus), en de nieuwe actor claimt de
oude identiteit in alsoKnownAs. Zonder het eerste kan elke ondertekende
vreemde onze follows ompointen; zonder het tweede kan wie een kant beheerst
de volgers van de andere kant kapen. Herbezorging is idempotent, een
geblokkeerde bestemming wordt geweigerd, en guardianship-relaties worden
bewust NIET geretarget: een guardian is een beveiligingsanker en verhuist
pas onder de regels van shaer-tge.

Deel 2: eigen aliassen instellen via Beheer, Sites, site bewerken. Een regel
per oud account, als @naam@server of als actor-URL. Handles resolven via
WebFinger op het moment van opslaan, zodat een typfout direct zichtbaar is
in plaats van pas bij een mislukkende Move op de oude server. De aliassen
verschijnen als alsoKnownAs op de publieke actor; dat is de claim die de
oude server controleert voor hij volgers hierheen verhuist.

Let op: FEP-7628 heeft status DRAFT. De vorm is sinds 2019 de facto
Mastodon-standaard, maar de spec kan nog wijzigen.

Changed files:
src/services/ActivityPubService.js

  • Move toegevoegd aan de GATED-lijst (handtekening verplicht)
  • dispatch-blok voor Move, voor de Follow-afhandeling (geen race met de re-Follow-golf van de nieuwe actor)
  • handleMoveInbox met injecteerbare afhankelijkheden voor de tests
  • alsoKnownAs en movedTo als JSON-LD-termen in de context
  • buildActor publiceert alsoKnownAs uit sites.ap_aliases, eigen id en niet-URLs gefilterd

src/config/database.js

  • kolom sites.ap_aliases (JSON-array van actor-URIs)

src/routes/admin-sites.js

  • parseApAliases: max 5, dedupe, WebFinger-resolutie bij opslaan, fout met de betreffende regel terug naar het formulier
  • save-route asynchroon; ap_aliases in de UPDATE
  • actor-Update naar volgers wanneer de lijst wijzigt (best-effort)

src/views/pages/admin-site-edit.ejs

  • veld Fediverse-aliassen onder de profiel-links

src/services/i18n.js

  • asite.aliases en asite.aliases_hint in nl, en en de

New file:
test/move-actor.test.js

  • 11 tests: derde-partij-weigering, ontbrekend aliasbewijs, push- en pull-modus, idempotentie, al-volgend overslaan, geblokkeerd doel, misvormde activiteiten, ongesigneerd, actor-publicatie met filtering

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

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