source: Klonkt/src/config/database.js@ 0101d0a

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

Fase 3: quotes federeren volgens de FEP, en de geciteerde auteur hoort het

De spiegel van wat we al aan de ingest-kant deden. Quoot je vanuit Klonkt een
fediverse-object, dan gaat dat nu ook echt de lijn over als quote, in de drie
vormen die het netwerk werkelijk leest: de FEP-044f quote-property, de de-facto
aliassen quoteUrl en _misskey_quote, en een FEP-e232 Link-tag. Alle drie wijzen
naar hetzelfde object, want elke lezer kijkt ergens anders.

En het belangrijkste: de geciteerde auteur komt in cc en zijn inbox in de
bezorglijst. Geciteerd worden zonder het te horen is precies de onbeleefdheid
die deze FEP probeert weg te ontwerpen.

De resolutie gebeurt een keer, bij publiceren (deliverCreate is toch al async),
en wordt opgeslagen op de post. buildNote blijft daardoor synchroon en hoeft
nooit te fetchen, ook niet als de outbox 'm opnieuw opbouwt.

Web-UI: een externe embed gebruikt nu dezelfde .tl-quote-kaart als een quote, dus
de belofte van een representatie klopt nu ook op het web.

Veiligheid: titel, provider en auteursnaam komen van een derde partij en worden
als PLATTE TEKST opgeslagen (tags eruit, lengte begrensd), zodat geen enkele
renderer verderop degene hoeft te zijn die aan escapen denkt. In de web-template
gaat de titel er bovendien nog een keer ge-escaped in, want de quote-kaart
rendert content als HTML (terecht voor AP-content, die we bij binnenkomst
saneren, maar niet voor dit).

Changed files:
src/config/database.js

  • posts.quote_uri en posts.quote_actor

src/services/ActivityPubService.js

  • applyQuoteProps: de drie quote-vormen + de auteur in cc
  • buildNote past ze toe; resolveOwnQuote resolvet een keer bij publiceren
  • deliverCreate bewaart het resultaat en bezorgt bij de geciteerde auteur
  • externe embed slaat titel/provider/auteur als platte tekst op

src/views/partials/tl-item.ejs

  • embed valt terug op dezelfde quote-kaart, met ge-escapete titel

test/external-embeds.test.js

  • 4 tests erbij: de drie vormen, de auteur in cc zonder de followers te verliezen, junk-invoer verandert niets, en er wordt nooit markup opgeslagen

remarks: 209 tests groen.

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

  • Property mode set to 100644
File size: 32.0 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 ensureColumn('sites', 'og_theme', 'TEXT'); // OG share-card variant: NULL=auto (follow site theme) | 'light' | 'dark'
140
141 // Per-post noindex + type
142 ensureColumn('posts', 'noindex', 'INTEGER DEFAULT 0');
143 ensureColumn('posts', 'publish_at', 'DATETIME'); // release planning (premium #3): scheduled go-live
144 ensureColumn('posts', 'fan_only', 'INTEGER DEFAULT 0'); // fan-only preview (premium #3)
145 ensureColumn('posts', 'nsfw', 'INTEGER DEFAULT 0'); // sensitive content โ†’ blur + click-to-reveal; fediverse sensitive
146 ensureColumn('posts', 'cover_video_url', 'TEXT'); // muted loop MP4 for an animated cover (Safari-smooth)
147 ensureColumn('posts', 'cover_alt', 'TEXT'); // alt text / description for the cover (a11y โ†’ AS2 attachment `name`)
148 ensureColumn('posts', 'language', 'TEXT'); // BCP-47 content language โ†’ federates as AS2 contentMap (Mastodon language filter/translate)
149 ensureColumn('posts', 'content_warning', 'TEXT'); // custom CW label (empty = default "Gevoelige inhoud")
150 ensureColumn('posts', 'type', "TEXT DEFAULT 'post'"); // post | foto | video | audio
151 ensureColumn('posts', 'poll_json', 'TEXT'); // a poll WE host โ†’ federates as AS2 Question: {multiple,options[{name}],endTime,closed}
152
153 // Statistics (premium module) โ€” bare counters, cookie-free.
154 ensureColumn('posts', 'view_count', 'INTEGER DEFAULT 0'); // views per post
155 ensureColumn('audio_tracks', 'play_count', 'INTEGER DEFAULT 0'); // plays per track
156 ensureColumn('audio_tracks', 'downloadable', 'INTEGER DEFAULT 0'); // download-for-email (premium #2)
157 ensureColumn('audio_tracks', 'credit', 'TEXT'); // owner/credit (copyright holder)
158 ensureColumn('audio_tracks', 'license', 'TEXT'); // license (e.g. "CC BY 4.0", "All rights reserved")
159 ensureColumn('audio_tracks', 'link_spotify', 'TEXT'); // "open in" links per track
160 ensureColumn('audio_tracks', 'link_youtube', 'TEXT');
161 ensureColumn('audio_tracks', 'link_soundcloud', 'TEXT');
162 // Per-track: federate the actual audio file as an AS2 Audio attachment so it plays inline
163 // in EVERY fediverse client (incl. the Mastodon apps). Default 0 = gated (web player only,
164 // file not exposed). Opt-in 1 = the file is served ungated + shared on the fediverse.
165 ensureColumn('audio_tracks', 'fedi_open', 'INTEGER DEFAULT 0');
166
167 // Playlists (v9 feature) โ€” first-class entity. CREATE IF NOT EXISTS is
168 // idempotent so it's safe to run on every boot regardless of DB age.
169 db.exec(`
170 CREATE TABLE IF NOT EXISTS playlists (
171 id TEXT PRIMARY KEY,
172 site_id TEXT NOT NULL,
173 title TEXT NOT NULL,
174 artist TEXT,
175 year INTEGER,
176 cover_url TEXT,
177 kind TEXT DEFAULT 'album',
178 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
179 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
180 FOREIGN KEY (site_id) REFERENCES sites(id)
181 );
182 CREATE TABLE IF NOT EXISTS playlist_tracks (
183 playlist_id TEXT NOT NULL,
184 track_id TEXT NOT NULL,
185 position INTEGER NOT NULL DEFAULT 0,
186 PRIMARY KEY (playlist_id, track_id),
187 FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
188 FOREIGN KEY (track_id) REFERENCES audio_tracks(id) ON DELETE CASCADE
189 );
190 CREATE INDEX IF NOT EXISTS idx_playlist_tracks_pos
191 ON playlist_tracks(playlist_id, position);
192 `);
193
194 // Global app settings (key/value singleton). Includes the tenancy mode
195 // (solo = one site, hub = company site + /user/). Default = solo.
196 db.exec(`
197 CREATE TABLE IF NOT EXISTS app_settings (
198 key TEXT PRIMARY KEY,
199 value TEXT,
200 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
201 );
202 `);
203 db.prepare("INSERT OR IGNORE INTO app_settings (key, value) VALUES ('tenancy', 'solo')").run();
204
205 // โ”€โ”€ Statistics (premium) โ€” cookie-free โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
206 // stat_daily: pageview count per day per site (bare counter).
207 // stat_visitor_day: one row per UNIQUE visitor hash per day per site
208 // (sha256 of IP+UA+day-salt; the salt rotates daily and is never stored
209 // โ†’ no persistent identifier, no cookie, no consent required).
210 db.exec(`
211 CREATE TABLE IF NOT EXISTS stat_daily (
212 site_id TEXT NOT NULL,
213 day TEXT NOT NULL,
214 pageviews INTEGER NOT NULL DEFAULT 0,
215 PRIMARY KEY (site_id, day)
216 );
217 CREATE TABLE IF NOT EXISTS stat_visitor_day (
218 site_id TEXT NOT NULL,
219 day TEXT NOT NULL,
220 visitor_hash TEXT NOT NULL,
221 PRIMARY KEY (site_id, day, visitor_hash)
222 );
223 CREATE INDEX IF NOT EXISTS idx_stat_visitor_day ON stat_visitor_day(site_id, day);
224 CREATE TABLE IF NOT EXISTS stat_referrer (
225 site_id TEXT NOT NULL,
226 host TEXT NOT NULL,
227 count INTEGER NOT NULL DEFAULT 0,
228 PRIMARY KEY (site_id, host)
229 );
230 `);
231
232 // Newsletter / mailing list (premium). Subscribers per site; double opt-in when SMTP
233 // is configured (status 'pending' until confirmed), otherwise single opt-in ('confirmed').
234 // 'unsub' = unsubscribed. token = confirm/unsubscribe key (used in email links).
235 db.exec(`
236 CREATE TABLE IF NOT EXISTS subscribers (
237 id TEXT PRIMARY KEY,
238 site_id TEXT NOT NULL,
239 email TEXT NOT NULL,
240 status TEXT NOT NULL DEFAULT 'pending',
241 source TEXT DEFAULT 'widget',
242 token TEXT NOT NULL,
243 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
244 confirmed_at DATETIME,
245 UNIQUE(site_id, email)
246 );
247 CREATE INDEX IF NOT EXISTS idx_subscribers_site_status ON subscribers(site_id, status);
248 `);
249
250 // Sent newsletters (history + counts).
251 db.exec(`
252 CREATE TABLE IF NOT EXISTS newsletters (
253 id TEXT PRIMARY KEY,
254 site_id TEXT NOT NULL,
255 subject TEXT NOT NULL,
256 body TEXT NOT NULL,
257 sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
258 recipient_count INTEGER DEFAULT 0
259 );
260 `);
261
262 // Show agenda (premium #8): tour dates / gigs per site.
263 db.exec(`
264 CREATE TABLE IF NOT EXISTS shows (
265 id TEXT PRIMARY KEY,
266 site_id TEXT NOT NULL,
267 date TEXT NOT NULL,
268 time TEXT,
269 city TEXT NOT NULL,
270 venue TEXT,
271 country TEXT,
272 ticket_url TEXT,
273 notes TEXT,
274 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
275 );
276 CREATE INDEX IF NOT EXISTS idx_shows_site_date ON shows(site_id, date);
277 `);
278
279 // Link-in-bio click statistics (premium #6). One counter per (site, url); the
280 // link-in-bio page links via /links/go/:i which counts the click and redirects.
281 db.exec(`
282 CREATE TABLE IF NOT EXISTS link_clicks (
283 site_id TEXT NOT NULL,
284 url TEXT NOT NULL,
285 clicks INTEGER DEFAULT 0,
286 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
287 PRIMARY KEY (site_id, url)
288 );
289 `);
290
291
292 // โ”€โ”€ ActivityPub (fediverse bridge) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
293 // RSA keypair per actor (Mastodon-compatible HTTP Signatures; separate from
294 // the Cirkels Ed25519 keys). ap_followers = remote AP actors following us.
295 db.exec(`
296 CREATE TABLE IF NOT EXISTS ap_keys (
297 slug TEXT PRIMARY KEY,
298 public_pem TEXT NOT NULL,
299 private_pem TEXT NOT NULL,
300 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
301 );
302 CREATE TABLE IF NOT EXISTS ap_followers (
303 id INTEGER PRIMARY KEY AUTOINCREMENT,
304 slug TEXT NOT NULL,
305 actor_uri TEXT NOT NULL,
306 inbox TEXT,
307 shared_inbox TEXT,
308 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
309 UNIQUE(slug, actor_uri)
310 );
311 CREATE INDEX IF NOT EXISTS idx_ap_followers_slug ON ap_followers(slug);
312 CREATE TABLE IF NOT EXISTS ap_interactions (
313 id INTEGER PRIMARY KEY AUTOINCREMENT,
314 kind TEXT NOT NULL, -- 'reply' | 'like' | 'announce'
315 post_id TEXT NOT NULL,
316 object_uri TEXT NOT NULL DEFAULT '', -- remote note id (reply) or '' (like/announce)
317 actor_uri TEXT NOT NULL,
318 actor_name TEXT,
319 actor_handle TEXT,
320 actor_url TEXT,
321 actor_icon TEXT,
322 content TEXT, -- sanitized HTML (reply)
323 published TEXT,
324 parent_uri TEXT, -- the note this reply replies to (for nesting)
325 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
326 UNIQUE(kind, post_id, actor_uri, object_uri)
327 );
328 CREATE INDEX IF NOT EXISTS idx_ap_inter_post ON ap_interactions(post_id, kind);
329 -- Moderation tombstones: object URIs the site owner removed. Checked at ingest
330 -- (handleInbox) AND by the thread-crawler, so a removed reply never comes back
331 -- via thread-filling. Private notes can't be flagged via authorize_interaction
332 -- (their fetch 401s), so owner moderation acts on the locally stored copy.
333 CREATE TABLE IF NOT EXISTS ap_rejected_objects (
334 object_uri TEXT PRIMARY KEY,
335 post_id TEXT,
336 reason TEXT,
337 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
338 );
339 -- ActivityPub C2S (client-to-server): OAuth 2.0 for native/web clients (Shaer).
340 -- Public clients + PKCE (RFC 8252); tokens stored hashed; token is per user+site.
341 CREATE TABLE IF NOT EXISTS oauth_clients (
342 client_id TEXT PRIMARY KEY,
343 client_name TEXT,
344 redirect_uris TEXT NOT NULL, -- JSON array
345 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
346 );
347 CREATE TABLE IF NOT EXISTS oauth_codes (
348 code TEXT PRIMARY KEY,
349 client_id TEXT NOT NULL,
350 user_id TEXT NOT NULL,
351 site_slug TEXT NOT NULL,
352 redirect_uri TEXT NOT NULL,
353 code_challenge TEXT, -- PKCE S256 (verplicht voor public clients)
354 scope TEXT,
355 expires_at DATETIME NOT NULL,
356 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
357 );
358 CREATE TABLE IF NOT EXISTS oauth_tokens (
359 token_hash TEXT PRIMARY KEY, -- sha256(bearer); het token zelf slaan we nooit op
360 client_id TEXT NOT NULL,
361 user_id TEXT NOT NULL,
362 site_slug TEXT NOT NULL,
363 scope TEXT,
364 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
365 last_used_at DATETIME
366 );
367 -- Paid posts (klonkt-demo-aki): the site owner's own Patreon campaign.
368 -- Secrets are encrypted at rest (CryptoBox). Never reuses the instance-level
369 -- patreon_* settings, which are Klonkt Premium's separate license flow.
370 CREATE TABLE IF NOT EXISTS paid_patreon (
371 site_id TEXT PRIMARY KEY,
372 client_id TEXT,
373 client_secret_enc TEXT,
374 campaign_id TEXT,
375 access_token_enc TEXT,
376 refresh_token_enc TEXT,
377 token_exp INTEGER, -- unix seconds
378 default_min_cents INTEGER DEFAULT 0,
379 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
380 );
381 -- One row per passkey. NO patron identity is stored (design decision):
382 -- {passkey, site, proven cents, expiry}. Not traceable to a person.
383 CREATE TABLE IF NOT EXISTS paid_entitlements (
384 credential_id TEXT PRIMARY KEY, -- WebAuthn credential id (opaque, base64url)
385 site_id TEXT NOT NULL,
386 public_key TEXT NOT NULL, -- COSE public key, base64url
387 counter INTEGER DEFAULT 0,
388 transports TEXT,
389 min_cents INTEGER DEFAULT 0, -- the amount proven at link time
390 expires_at INTEGER NOT NULL, -- unix seconds; re-link after
391 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
392 );
393 -- Web Push (docs/webpush-design.md): one row per browser/device the owner
394 -- enabled notifications on. Payloads are encrypted to p256dh/auth (RFC 8291).
395 CREATE TABLE IF NOT EXISTS push_subscriptions (
396 endpoint TEXT PRIMARY KEY, -- push-service URL for this device
397 user_id TEXT NOT NULL,
398 p256dh TEXT NOT NULL, -- client public key
399 auth TEXT NOT NULL, -- client auth secret
400 alert_types TEXT, -- JSON {follow,reply,like,boost,dm}
401 ua_label TEXT,
402 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
403 last_ok_at DATETIME
404 );
405 CREATE TABLE IF NOT EXISTS ap_outbox (
406 id TEXT PRIMARY KEY, -- note path segment (uuid) โ†’ /ap/notes/<id>
407 site_slug TEXT NOT NULL,
408 post_id TEXT NOT NULL,
409 post_slug TEXT,
410 in_reply_to TEXT, -- remote status uri we reply to
411 to_actor TEXT, -- remote actor uri (mentioned)
412 to_handle TEXT,
413 content TEXT NOT NULL, -- sanitized HTML of our reply
414 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
415 );
416 CREATE INDEX IF NOT EXISTS idx_ap_outbox_post ON ap_outbox(post_id);
417 -- Your like/boost state on a REMOTE post (the interact page), so those become toggles.
418 CREATE TABLE IF NOT EXISTS ap_my_reactions (
419 site_slug TEXT NOT NULL,
420 target_uri TEXT NOT NULL,
421 kind TEXT NOT NULL, -- 'like' | 'boost'
422 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
423 UNIQUE(site_slug, target_uri, kind)
424 );
425 `);
426 ensureColumn('ap_interactions', 'parent_uri', 'TEXT'); // nesting (existing DBs)
427 ensureColumn('ap_interactions', 'acted_boost', 'INTEGER DEFAULT 0'); // owner boosted this comment (๐Ÿ”) โ†’ can undo
428 ensureColumn('ap_interactions', 'acted_like', 'INTEGER DEFAULT 0'); // owner liked this comment (โญ) โ†’ can undo
429
430 // Fediverse CLIENT: accounts WE follow (outbound) + the home timeline of their posts.
431 db.exec(`
432 CREATE TABLE IF NOT EXISTS ap_following (
433 id INTEGER PRIMARY KEY AUTOINCREMENT,
434 slug TEXT NOT NULL, -- our site that follows
435 actor_uri TEXT NOT NULL, -- the followed account's actor id
436 handle TEXT, name TEXT, icon TEXT, url TEXT,
437 inbox TEXT, -- their inbox (for Create delivery / Undo)
438 follow_id TEXT, -- the Follow activity id we sent (Accept matching)
439 status TEXT DEFAULT 'pending', -- pending | accepted
440 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
441 UNIQUE(slug, actor_uri)
442 );
443 CREATE TABLE IF NOT EXISTS ap_timeline (
444 id TEXT NOT NULL, -- the remote note's AP id
445 slug TEXT NOT NULL, -- whose home timeline (our site)
446 author_uri TEXT, author_name TEXT, author_handle TEXT, author_icon TEXT, author_url TEXT,
447 content TEXT, url TEXT, published TEXT, media_json TEXT,
448 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
449 UNIQUE(slug, id)
450 );
451 CREATE INDEX IF NOT EXISTS idx_ap_timeline_slug ON ap_timeline(slug, published);
452 CREATE TABLE IF NOT EXISTS ap_blocks (
453 id INTEGER PRIMARY KEY AUTOINCREMENT,
454 slug TEXT NOT NULL, -- our site that set the block
455 target TEXT NOT NULL, -- actor URI (actor block) or domain (domain block)
456 kind TEXT NOT NULL, -- 'actor' | 'domain'
457 label TEXT, -- display (@handle or domain)
458 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
459 UNIQUE(slug, target)
460 );
461 CREATE INDEX IF NOT EXISTS idx_ap_blocks_target ON ap_blocks(target);
462 -- Committed guardian โ†” ward relations, one row per local side. role
463 -- 'ward' = the local slug is a ward of other_uri; 'guardian' = the local
464 -- slug guards other_uri. status is always 'accepted' here now: PENDING
465 -- offers live in ap_guardian_offers below (FEP-633c multi-party handshake).
466 CREATE TABLE IF NOT EXISTS ap_guardianships (
467 id INTEGER PRIMARY KEY AUTOINCREMENT,
468 slug TEXT NOT NULL, -- our local site in this relation (guardianship module)
469 role TEXT NOT NULL, -- 'guardian' (slug guards other) | 'ward' (other guards slug)
470 other_uri TEXT NOT NULL, -- the counterpart actor URI (local or remote)
471 other_handle TEXT, -- cached @user@host for display
472 status TEXT NOT NULL, -- 'offered' (legacy) | 'accepted'
473 offer_id TEXT, -- the Offer activity id (FEP-633c section 3)
474 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
475 UNIQUE(slug, role, other_uri)
476 );
477 CREATE INDEX IF NOT EXISTS idx_ap_guardianships_slug ON ap_guardianships(slug, role, status);
478 -- The multi-party handshake (FEP-633c section 3), one row per offer this
479 -- instance is a party to. Mirrors the Shaer test daemon's Handshake:
480 -- accepts accumulate in ap_guardian_offer_accepts, and the offer commits
481 -- only when the candidate returns the handle after ward + candidate + at
482 -- least one existing guardian have accepted.
483 CREATE TABLE IF NOT EXISTS ap_guardian_offers (
484 offer_id TEXT NOT NULL, -- the Offer activity id (minted by the candidate)
485 slug TEXT NOT NULL, -- the local site tracking this handshake (each party keeps its own copy)
486 ward_uri TEXT NOT NULL, -- the ward-to-be
487 candidate_uri TEXT NOT NULL, -- the guardian-candidate (fixed initiator)
488 existing_guardians TEXT NOT NULL DEFAULT '[]', -- JSON array of the ward's current guardian URIs
489 status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'committed' | 'void'
490 handle TEXT, -- the escalation handle returned at commit (section 6)
491 ward_handle TEXT, -- cached @ward@host for display
492 candidate_handle TEXT, -- cached @candidate@host for display
493 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
494 PRIMARY KEY (slug, offer_id)
495 );
496 CREATE INDEX IF NOT EXISTS idx_ap_guardian_offers_slug ON ap_guardian_offers(slug, status);
497 CREATE TABLE IF NOT EXISTS ap_guardian_offer_accepts (
498 offer_id TEXT NOT NULL, -- FK to ap_guardian_offers
499 slug TEXT NOT NULL, -- the local site's copy of the tally
500 party_uri TEXT NOT NULL, -- the party who accepted (ward | candidate | an existing guardian)
501 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
502 PRIMARY KEY (slug, offer_id, party_uri)
503 );
504 CREATE TABLE IF NOT EXISTS ap_delivery (
505 id INTEGER PRIMARY KEY AUTOINCREMENT,
506 slug TEXT NOT NULL, -- our site/actor that signs the delivery
507 inbox TEXT NOT NULL, -- recipient inbox URL
508 body TEXT NOT NULL, -- the activity JSON to POST
509 attempts INTEGER NOT NULL DEFAULT 0,
510 next_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
511 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
512 );
513 CREATE INDEX IF NOT EXISTS idx_ap_delivery_due ON ap_delivery(next_at);
514 CREATE TABLE IF NOT EXISTS poll_votes (
515 id INTEGER PRIMARY KEY AUTOINCREMENT,
516 post_id INTEGER NOT NULL, -- our local poll post (posts.id)
517 actor_uri TEXT NOT NULL, -- the remote voter's AP actor URI
518 choice TEXT NOT NULL, -- the chosen option's name (matches poll_json options[].name)
519 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
520 UNIQUE(post_id, actor_uri, choice)
521 );
522 CREATE INDEX IF NOT EXISTS idx_poll_votes_post ON poll_votes(post_id);
523 CREATE TABLE IF NOT EXISTS ap_mentions (
524 id INTEGER PRIMARY KEY AUTOINCREMENT,
525 slug TEXT NOT NULL, -- our mentioned site/actor
526 object_uri TEXT NOT NULL, -- the remote note that mentions us
527 note_url TEXT, -- its human URL (open/interact)
528 actor_uri TEXT, actor_name TEXT, actor_handle TEXT, actor_icon TEXT, actor_url TEXT,
529 content TEXT, -- sanitized HTML snippet of the mentioning note
530 published TEXT,
531 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
532 UNIQUE(slug, object_uri)
533 );
534 CREATE INDEX IF NOT EXISTS idx_ap_mentions_slug ON ap_mentions(slug, created_at);
535 CREATE TABLE IF NOT EXISTS ap_reports (
536 id INTEGER PRIMARY KEY AUTOINCREMENT,
537 slug TEXT NOT NULL, -- our site the report is about (its owner moderates)
538 actor_uri TEXT, -- the reporter's actor URI
539 actor_name TEXT, actor_handle TEXT, actor_icon TEXT,
540 content TEXT, -- the reason (plain text)
541 objects TEXT, -- JSON array of reported object URIs (our actor + statuses)
542 seen INTEGER DEFAULT 0,
543 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
544 );
545 CREATE INDEX IF NOT EXISTS idx_ap_reports_slug ON ap_reports(slug, created_at);
546 `);
547 // "Feature" a followed account: its posts show in the local Cirkel.
548 ensureColumn('ap_following', 'auto_boost', 'INTEGER DEFAULT 0');
549 // A timeline post you boosted (๐Ÿ”) โ€” also shown in the Cirkel (mixed by date).
550 ensureColumn('ap_timeline', 'boosted', 'INTEGER DEFAULT 0');
551 ensureColumn('ap_timeline', 'liked', 'INTEGER DEFAULT 0'); // a feed post you liked (โญ) โ†’ toggle
552 ensureColumn('ap_timeline', 'nsfw', 'INTEGER DEFAULT 0'); // remote sensitive post โ†’ blur in the Cirkel
553 ensureColumn('ap_timeline', 'cw', 'TEXT'); // remote content-warning text
554 ensureColumn('ap_timeline', 'emoji_json', 'TEXT'); // FEP-9098 custom emoji Emoji tags from the inbound note, served back as `tag`
555 ensureColumn('ap_timeline', 'link_json', 'TEXT'); // FEP-e232 object-link (quote/ref) tags from the inbound note, served back as `tag`
556 ensureColumn('ap_timeline', 'quote_json', 'TEXT'); // FEP-044f resolved quoted-post snapshot (author + content), for the embedded quote card
557 // FEP-044f: the fediverse object THIS post quotes, resolved once at publish
558 // time so buildNote (sync, also used by the outbox) needs no network.
559 ensureColumn('posts', 'quote_uri', 'TEXT'); // the quoted object's id
560 ensureColumn('posts', 'quote_actor', 'TEXT'); // its author, so we can address them
561 ensureColumn('ap_timeline', 'embed_json', 'TEXT'); // resolved EXTERNAL embed (oEmbed/provider), thumbnail-only; gated per site (sites.external_embeds)
562 ensureColumn('ap_timeline', 'author_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the author's display name (shaer:author.emojis)
563 ensureColumn('ap_timeline', 'reblog_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the booster's display name (shaer:booster.emojis)
564 ensureColumn('ap_timeline', 'reblog_name', 'TEXT'); // a followed account boosted this โ†’ "X boosted"
565 ensureColumn('ap_timeline', 'reblog_handle', 'TEXT'); // the booster's @handle
566 ensureColumn('ap_timeline', 'reblog_icon', 'TEXT'); // the booster's avatar
567 ensureColumn('ap_timeline', 'poll_json', 'TEXT'); // a Question (poll): {multiple,options[{name,count}],endTime,closed,voters,voted}
568
569 // Delivery health per follower โ†’ surface dead accounts for manual cleanup.
570 ensureColumn('ap_followers', 'last_delivery_at', 'DATETIME'); // last SUCCESSFUL delivery to this follower's inbox
571 ensureColumn('ap_followers', 'last_error_at', 'DATETIME'); // last time a delivery to it gave up (max retries)
572
573 // ActivityPub `source` model: content_rendered = baked display HTML (#hashtags / URLs /
574 // @mentions linkified once at save). `content` stays the raw source used for editing and
575 // re-rendering. NULL on old posts โ†’ the render route bakes on the fly as a fallback.
576 ensureColumn('posts', 'content_rendered', 'TEXT');
577
578 // AP addressing of an incoming interaction: 'public' | 'unlisted' | 'followers' | 'direct',
579 // derived from the note's to/cc at ingest. The public post page only renders public/unlisted
580 // replies; followers/direct replies surface in notifications (and later Messages) with post
581 // context instead. Existing rows default to 'public' (historically almost all were).
582 ensureColumn('ap_interactions', 'visibility', "TEXT DEFAULT 'public'");
583 ensureColumn('ap_interactions', 'emoji_json', 'TEXT'); // FEP-9098 custom emojis in a reply's content (messages + thread)
584 ensureColumn('ap_interactions', 'actor_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the reply author's display name
585 // Rich replies: the reply's language (BCP47 code) โ†’ contentMap on the outgoing Note.
586 ensureColumn('ap_outbox', 'language', 'TEXT');
587 // Rich replies: JSON array [{url, mediaType, name}] โ†’ `attachment` on the Note.
588 ensureColumn('ap_outbox', 'attachments', 'TEXT');
589 ensureColumn('posts', 'ap_visibility', 'TEXT'); // public|quiet|friends|direct (C2S addressing, shaer-60b)
590 ensureColumn('posts', 'paid', 'INTEGER DEFAULT 0'); // paid post (klonkt-demo-aki)
591 ensureColumn('posts', 'paid_min_cents', 'INTEGER'); // required support; null = owner default
592 ensureColumn('paid_patreon', 'patreon_url', 'TEXT'); // owner's public Patreon page โ†’ "Word supporter" link (klonkt-demo-aki)
593 ensureColumn('ap_outbox', 'visibility', 'TEXT'); // 'direct' = private mention, never Public (shaer-tqc)
594 ensureColumn('ap_outbox', 'to_actors', 'TEXT'); // JSON array of recipient actor URIs for direct notes
595 ensureColumn('ap_outbox', 'help_request', 'INTEGER'); // FEP-633c shaer:helpRequest (ward's call for help)
596 ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
597 ensureColumn('ap_outbox', 'wave', 'INTEGER'); // FEP-633c shaer:wave (guardian -> ward nudge)
598 ensureColumn('ap_mentions', 'wave', 'INTEGER'); // inbound guardian wave
599 // FEP-633c ยง2.2: object hint that the author is a ward. Register-only for now;
600 // used later at reddings-boei / escalation routing.
601 ensureColumn('ap_timeline', 'has_guardians', 'INTEGER');
602 ensureColumn('ap_mentions', 'has_guardians', 'INTEGER');
603 ensureColumn('ap_followers', 'name', 'TEXT'); // cached display name (shaer-aa3)
604 ensureColumn('ap_followers', 'handle', 'TEXT'); // @user@host
605 ensureColumn('ap_followers', 'icon', 'TEXT'); // avatar URL
606}
607
608function ensureColumn(table, column, definition) {
609 try {
610 db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
611 console.log(`๐Ÿ”ง Added column ${table}.${column}`);
612 } catch (e) {
613 // "duplicate column name" โ†’ already there. Anything else, surface it.
614 if (!/duplicate column/i.test(e.message)) {
615 console.error(`โŒ ensureColumn(${table}.${column}):`, e.message);
616 }
617 }
618}
619
620export default db;
Note: See TracBrowser for help on using the repository browser.