source: Klonkt/src/config/database.js@ 88d7c8f

main
Last change on this file since 88d7c8f was 88d7c8f, checked in by Robin Genis <roboburr@…>, 6 weeks ago

Een gated voorstel bereikte de andere guardians nooit

Op de vloot nagekeken waarom YouTube-voorbeelden bij beta uit blijven: het
voorstel van sound-fabrics staat er, met een ja van een van de drie guardians,
en het is stil verlopen. Niet omdat iemand bezwaar had, maar omdat niemand
anders het ooit gezien heeft.

Het voorstel wordt geadresseerd aan de server van het kind, want die telt en
handhaaft (5.6). Maar daarmee bereikt het alleen de voorsteller en het kind. De
twee guardians op andere servers weten van niets, kunnen dus niet antwoorden, en
een drempel van twee is onhaalbaar. Elk voorstel verloopt na een dag.

De ontbrekende schakel is het doorsturen, precies wat 5.3 al doet voor een
gated follow: de server van het kind kent de gezaghebbende guardian-lijst, dus
die stuurt het voorstel door. Elke guardian bewaart een kopie die hij kan
beantwoorden, en het antwoord reist terug naar het kind, dat telt.

Changed files:
src/config/database.js

  • tabel ap_gated_reviews, de guardian-kopie (zelfde vorm als ap_follow_reviews)

src/services/guardianship/gated.js

  • de kopie-opslag: bewaren, lezen, beantwoorden, opruimen

src/services/guardianship/handshake.js

  • ward-kant: doorsturen naar de andere guardians zodra het voorstel openstaat
  • guardian-kant: de doorgestuurde kopie bewaren om te kunnen antwoorden

src/routes/guardian.js

  • gatedReviews in de dashboardstaat; POST /guardian/api/gated/:id stuurt het antwoord naar de inbox van het kind

src/assets/js/guardian.js, src/assets/css/guardian.css

src/services/i18n.js

  • de teksten in nl, en, de

test/gated-settings.test.js

  • de hele keten: voorstellen, doorsturen naar allebei de anderen, de kopie opslaan, antwoorden, en pas bij twee van drie gaat de gate open

remarks: dit forceert niets; het maakt alleen mogelijk wat de spec al bedoelde.
Twee van de drie guardians moeten nog steeds akkoord gaan, en het venster is
24 uur.

-robo
Co-Authored-By: Claude Opus 5 <noreply@…>

  • Property mode set to 100644
File size: 36.7 KB
RevLine 
[7bc636b]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');
[7b07035]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
[7bc636b]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');
[05665bc]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 )`);
[5c373b8]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 )`);
[2b4252c]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 )`);
[7bc636b]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');
[834bcc3]95 // Google OAuth: link a Google account to a user (login via Google).
[c80e78b]96 ensureColumn('users', 'google_sub', 'TEXT');
[834bcc3]97 // Read-only/viewer account: can view everything but make no changes.
[640b39c]98 ensureColumn('users', 'readonly', 'INTEGER DEFAULT 0');
[834bcc3]99 // Personal interface language (nl|en|de). Null = follow the default (site/env/browser).
[5e61b17]100 ensureColumn('users', 'lang', 'TEXT');
[7bc636b]101 // Site-level moderation toggle. 'trust' = auto-approve, 'moderate' = pending until reviewed.
[834bcc3]102 // Circles: whether this site may appear in other sites' circles (surfacing opt-out).
[0091cb7]103 ensureColumn('sites', 'allow_circle', 'INTEGER DEFAULT 1');
[7bc636b]104
[834bcc3]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.
[7881080]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 }
[834bcc3]116 } catch (e) { /* sites table still empty/absent on fresh init — ensurePrimarySite handles it */ }
[7881080]117
[7bc636b]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}]
[8ea3d0d]130 ensureColumn('sites', 'feed_view_default', "TEXT DEFAULT 'grid'"); // timeline | grid
[7bc636b]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');
[fc40410]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'
[7bc636b]140
141 // Per-post noindex + type
142 ensureColumn('posts', 'noindex', 'INTEGER DEFAULT 0');
[834bcc3]143 ensureColumn('posts', 'publish_at', 'DATETIME'); // release planning (premium #3): scheduled go-live
[b9dc94c]144 ensureColumn('posts', 'fan_only', 'INTEGER DEFAULT 0'); // fan-only preview (premium #3)
[837fc9c]145 ensureColumn('posts', 'nsfw', 'INTEGER DEFAULT 0'); // sensitive content → blur + click-to-reveal; fediverse sensitive
[1d6f9a2]146 ensureColumn('posts', 'cover_video_url', 'TEXT'); // muted loop MP4 for an animated cover (Safari-smooth)
[d18c60e]147 ensureColumn('posts', 'cover_alt', 'TEXT'); // alt text / description for the cover (a11y → AS2 attachment `name`)
[0688b5f]148 ensureColumn('posts', 'language', 'TEXT'); // BCP-47 content language → federates as AS2 contentMap (Mastodon language filter/translate)
[b7d4458]149 ensureColumn('posts', 'content_warning', 'TEXT'); // custom CW label (empty = default "Gevoelige inhoud")
[7bc636b]150 ensureColumn('posts', 'type', "TEXT DEFAULT 'post'"); // post | foto | video | audio
[0403187]151 ensureColumn('posts', 'poll_json', 'TEXT'); // a poll WE host → federates as AS2 Question: {multiple,options[{name}],endTime,closed}
[7bc636b]152
[834bcc3]153 // Statistics (premium module) — bare counters, cookie-free.
154 ensureColumn('posts', 'view_count', 'INTEGER DEFAULT 0'); // views per post
[d549549]155 ensureColumn('audio_tracks', 'play_count', 'INTEGER DEFAULT 0'); // plays per track
[834bcc3]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
[183875b]160 ensureColumn('audio_tracks', 'link_youtube', 'TEXT');
161 ensureColumn('audio_tracks', 'link_soundcloud', 'TEXT');
[f2eacca]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');
[d549549]166
[7bc636b]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 `);
[6351545]193
[834bcc3]194 // Global app settings (key/value singleton). Includes the tenancy mode
195 // (solo = one site, hub = company site + /user/). Default = solo.
[6351545]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();
[b300682]204
[834bcc3]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).
[d549549]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);
[1794fac]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 );
[d549549]230 `);
231
[834bcc3]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).
[2e247e4]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
[834bcc3]250 // Sent newsletters (history + counts).
[2e247e4]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 `);
[37edecd]261
[834bcc3]262 // Show agenda (premium #8): tour dates / gigs per site.
[8d32dcf]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
[834bcc3]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.
[37edecd]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 `);
[535f955]290
[6bd25d1]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);
[c16e0a5]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,
[7d932ce]324 parent_uri TEXT, -- the note this reply replies to (for nesting)
[c16e0a5]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);
[67c1f24]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 );
[d49b60b]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 );
[61e3daf]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 );
[9e9e6f9]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 );
[ad10715]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 );
[55bc7f9]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);
[3d37c67]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 );
[6bd25d1]425 `);
[7d932ce]426 ensureColumn('ap_interactions', 'parent_uri', 'TEXT'); // nesting (existing DBs)
[c745659]427 ensureColumn('ap_interactions', 'acted_boost', 'INTEGER DEFAULT 0'); // owner boosted this comment (🔁) → can undo
[3289a64]428 ensureColumn('ap_interactions', 'acted_like', 'INTEGER DEFAULT 0'); // owner liked this comment (⭐) → can undo
[914eb9f]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);
[f5c3870]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);
[780a7c6]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).
[6b5d7da]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
[780a7c6]472 status TEXT NOT NULL, -- 'offered' (legacy) | 'accepted'
[6b5d7da]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);
[780a7c6]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 );
[65abc85]504 -- FEP-633c §5.6: a gated setting a ward's guardians decide together, which
505 -- has to work when they live on other servers (the ordinary case). One row
506 -- per guardian answer; the ward's server tallies (§3.5) and enforces.
507 -- The proposals themselves, so an Accept that only references the offer
508 -- id can still be resolved to "which feature, which value".
509 CREATE TABLE IF NOT EXISTS ap_gated_offers (
510 offer_id TEXT PRIMARY KEY,
511 slug TEXT NOT NULL, -- the ward, on this server
512 feature TEXT NOT NULL,
513 value INTEGER NOT NULL,
514 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
515 );
[88d7c8f]516 -- The guardian-side COPY of a gated-setting proposal on a ward, forwarded
517 -- here by the WARD's server (the same shape ap_follow_reviews has for a
518 -- gated follow). Without it a guardian on another server never learns a
519 -- proposal exists and can never answer it, so a threshold of two can never
520 -- be reached and every proposal expires. The answer goes back to the
521 -- ward's inbox, which tallies (5.6).
522 CREATE TABLE IF NOT EXISTS ap_gated_reviews (
523 id TEXT NOT NULL, -- the offer id, as minted by the proposer
524 guardian_slug TEXT NOT NULL, -- us, one of the ward's guardians
525 ward_uri TEXT NOT NULL,
526 ward_inbox TEXT,
527 proposer TEXT, -- who opened it (for display)
528 feature TEXT NOT NULL,
529 value INTEGER NOT NULL,
530 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
531 PRIMARY KEY (guardian_slug, id)
532 );
[65abc85]533 CREATE TABLE IF NOT EXISTS ap_gated_votes (
534 slug TEXT NOT NULL, -- the WARD, on this server
535 feature TEXT NOT NULL, -- e.g. 'shaer:externalEmbeds'
536 guardian_uri TEXT NOT NULL, -- who answered (must be a committed guardian)
537 value INTEGER NOT NULL, -- the value they voted for (0/1)
538 opened_at DATETIME NOT NULL, -- when this decision opened (the window start)
539 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
540 PRIMARY KEY (slug, feature, guardian_uri)
541 );
[6eab7e9]542 -- Guardian availability (FEP-633c 3.6): one guardian's attention as seen
543 -- from one ward on this server. Never public; the ward reads it via the
544 -- owner-only guardians queue. One rule above all: one answer restores
545 -- everything, so every row here is one answer away from disappearing.
546 CREATE TABLE IF NOT EXISTS ap_guardian_attention (
547 ward_slug TEXT NOT NULL,
548 guardian_uri TEXT NOT NULL,
549 state TEXT NOT NULL DEFAULT 'active', -- 'active' | 'away' | 'dormant'
550 away_until INTEGER, -- epoch ms while declared away
551 PRIMARY KEY (ward_slug, guardian_uri)
552 );
553 -- The ONLY admissible dormancy evidence (3.6.2): directly addressed
554 -- requests that went unanswered. Calendar time alone never counts.
555 CREATE TABLE IF NOT EXISTS ap_attention_requests (
556 ward_slug TEXT NOT NULL,
557 guardian_uri TEXT NOT NULL,
558 request_id TEXT NOT NULL,
559 asked_at INTEGER NOT NULL, -- epoch ms
560 PRIMARY KEY (ward_slug, guardian_uri, request_id)
561 );
562 -- A lapse (3.6.3): the available co-guardians deciding to release a
563 -- dormant one. Irreversible, so the window always runs in full; any sign
564 -- of life from the target cancels it outright.
565 CREATE TABLE IF NOT EXISTS ap_lapses (
566 id TEXT PRIMARY KEY,
567 ward_slug TEXT NOT NULL,
568 ward_uri TEXT NOT NULL,
569 target_uri TEXT NOT NULL,
570 opened_by TEXT NOT NULL,
571 set_json TEXT NOT NULL, -- the available set at open, target excluded
572 accepts_json TEXT NOT NULL DEFAULT '[]',
573 rejects_json TEXT NOT NULL DEFAULT '[]',
574 opened_at INTEGER NOT NULL, -- epoch ms
575 window_ms INTEGER NOT NULL,
576 cancelled INTEGER NOT NULL DEFAULT 0,
577 applied INTEGER NOT NULL DEFAULT 0,
578 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
579 );
[5a6a457]580 CREATE TABLE IF NOT EXISTS ap_delivery (
581 id INTEGER PRIMARY KEY AUTOINCREMENT,
582 slug TEXT NOT NULL, -- our site/actor that signs the delivery
583 inbox TEXT NOT NULL, -- recipient inbox URL
584 body TEXT NOT NULL, -- the activity JSON to POST
585 attempts INTEGER NOT NULL DEFAULT 0,
586 next_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
587 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
588 );
589 CREATE INDEX IF NOT EXISTS idx_ap_delivery_due ON ap_delivery(next_at);
[0403187]590 CREATE TABLE IF NOT EXISTS poll_votes (
591 id INTEGER PRIMARY KEY AUTOINCREMENT,
592 post_id INTEGER NOT NULL, -- our local poll post (posts.id)
593 actor_uri TEXT NOT NULL, -- the remote voter's AP actor URI
594 choice TEXT NOT NULL, -- the chosen option's name (matches poll_json options[].name)
595 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
596 UNIQUE(post_id, actor_uri, choice)
597 );
598 CREATE INDEX IF NOT EXISTS idx_poll_votes_post ON poll_votes(post_id);
[fe97cc3]599 CREATE TABLE IF NOT EXISTS ap_mentions (
600 id INTEGER PRIMARY KEY AUTOINCREMENT,
601 slug TEXT NOT NULL, -- our mentioned site/actor
602 object_uri TEXT NOT NULL, -- the remote note that mentions us
603 note_url TEXT, -- its human URL (open/interact)
604 actor_uri TEXT, actor_name TEXT, actor_handle TEXT, actor_icon TEXT, actor_url TEXT,
605 content TEXT, -- sanitized HTML snippet of the mentioning note
606 published TEXT,
607 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
608 UNIQUE(slug, object_uri)
609 );
610 CREATE INDEX IF NOT EXISTS idx_ap_mentions_slug ON ap_mentions(slug, created_at);
[737ea05]611 CREATE TABLE IF NOT EXISTS ap_reports (
612 id INTEGER PRIMARY KEY AUTOINCREMENT,
613 slug TEXT NOT NULL, -- our site the report is about (its owner moderates)
614 actor_uri TEXT, -- the reporter's actor URI
615 actor_name TEXT, actor_handle TEXT, actor_icon TEXT,
616 content TEXT, -- the reason (plain text)
617 objects TEXT, -- JSON array of reported object URIs (our actor + statuses)
618 seen INTEGER DEFAULT 0,
619 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
620 );
621 CREATE INDEX IF NOT EXISTS idx_ap_reports_slug ON ap_reports(slug, created_at);
[914eb9f]622 `);
[5045c30]623 // "Feature" a followed account: its posts show in the local Cirkel.
[f278df9]624 ensureColumn('ap_following', 'auto_boost', 'INTEGER DEFAULT 0');
[5045c30]625 // A timeline post you boosted (🔁) — also shown in the Cirkel (mixed by date).
626 ensureColumn('ap_timeline', 'boosted', 'INTEGER DEFAULT 0');
[9d34855]627 ensureColumn('ap_timeline', 'liked', 'INTEGER DEFAULT 0'); // a feed post you liked (⭐) → toggle
[b7d4458]628 ensureColumn('ap_timeline', 'nsfw', 'INTEGER DEFAULT 0'); // remote sensitive post → blur in the Cirkel
629 ensureColumn('ap_timeline', 'cw', 'TEXT'); // remote content-warning text
[97bacf2]630 ensureColumn('ap_timeline', 'emoji_json', 'TEXT'); // FEP-9098 custom emoji Emoji tags from the inbound note, served back as `tag`
[eb36688]631 ensureColumn('ap_timeline', 'link_json', 'TEXT'); // FEP-e232 object-link (quote/ref) tags from the inbound note, served back as `tag`
[6fd0e20]632 ensureColumn('ap_timeline', 'quote_json', 'TEXT'); // FEP-044f resolved quoted-post snapshot (author + content), for the embedded quote card
[b258a79]633 // FEP-044f: the fediverse object THIS post quotes, resolved once at publish
634 // time so buildNote (sync, also used by the outbox) needs no network.
635 ensureColumn('posts', 'quote_uri', 'TEXT'); // the quoted object's id
636 ensureColumn('posts', 'quote_actor', 'TEXT'); // its author, so we can address them
637 ensureColumn('ap_timeline', 'embed_json', 'TEXT'); // resolved EXTERNAL embed (oEmbed/provider), thumbnail-only; gated per site (sites.external_embeds)
[a677616]638 ensureColumn('ap_timeline', 'author_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the author's display name (shaer:author.emojis)
[f3caf19]639 ensureColumn('ap_timeline', 'reblog_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the booster's display name (shaer:booster.emojis)
[c6cdce6]640 ensureColumn('ap_timeline', 'reblog_name', 'TEXT'); // a followed account boosted this → "X boosted"
641 ensureColumn('ap_timeline', 'reblog_handle', 'TEXT'); // the booster's @handle
642 ensureColumn('ap_timeline', 'reblog_icon', 'TEXT'); // the booster's avatar
[6053c6c]643 ensureColumn('ap_timeline', 'poll_json', 'TEXT'); // a Question (poll): {multiple,options[{name,count}],endTime,closed,voters,voted}
[8878814]644
645 // Delivery health per follower → surface dead accounts for manual cleanup.
646 ensureColumn('ap_followers', 'last_delivery_at', 'DATETIME'); // last SUCCESSFUL delivery to this follower's inbox
647 ensureColumn('ap_followers', 'last_error_at', 'DATETIME'); // last time a delivery to it gave up (max retries)
[2d6a9c3]648
649 // ActivityPub `source` model: content_rendered = baked display HTML (#hashtags / URLs /
650 // @mentions linkified once at save). `content` stays the raw source used for editing and
651 // re-rendering. NULL on old posts → the render route bakes on the fly as a fallback.
652 ensureColumn('posts', 'content_rendered', 'TEXT');
[3778ddb]653
654 // AP addressing of an incoming interaction: 'public' | 'unlisted' | 'followers' | 'direct',
655 // derived from the note's to/cc at ingest. The public post page only renders public/unlisted
656 // replies; followers/direct replies surface in notifications (and later Messages) with post
657 // context instead. Existing rows default to 'public' (historically almost all were).
658 ensureColumn('ap_interactions', 'visibility', "TEXT DEFAULT 'public'");
[ef1853c]659 ensureColumn('ap_interactions', 'emoji_json', 'TEXT'); // FEP-9098 custom emojis in a reply's content (messages + thread)
660 ensureColumn('ap_interactions', 'actor_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the reply author's display name
[33e1dbd]661 // Rich replies: the reply's language (BCP47 code) → contentMap on the outgoing Note.
662 ensureColumn('ap_outbox', 'language', 'TEXT');
[feced2c]663 // Rich replies: JSON array [{url, mediaType, name}] → `attachment` on the Note.
664 ensureColumn('ap_outbox', 'attachments', 'TEXT');
[81b2e1e]665 ensureColumn('posts', 'ap_visibility', 'TEXT'); // public|quiet|friends|direct (C2S addressing, shaer-60b)
[928d1c7]666 ensureColumn('posts', 'paid', 'INTEGER DEFAULT 0'); // paid post (klonkt-demo-aki)
667 ensureColumn('posts', 'paid_min_cents', 'INTEGER'); // required support; null = owner default
[c3d12a6]668 ensureColumn('paid_patreon', 'patreon_url', 'TEXT'); // owner's public Patreon page → "Word supporter" link (klonkt-demo-aki)
[024f4f8]669 ensureColumn('ap_outbox', 'visibility', 'TEXT'); // 'direct' = private mention, never Public (shaer-tqc)
670 ensureColumn('ap_outbox', 'to_actors', 'TEXT'); // JSON array of recipient actor URIs for direct notes
[155c24e]671 ensureColumn('ap_outbox', 'help_request', 'INTEGER'); // FEP-633c shaer:helpRequest (ward's call for help)
[6b5d7da]672 ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
[e62f65d]673 ensureColumn('ap_outbox', 'wave', 'INTEGER'); // FEP-633c shaer:wave (guardian -> ward nudge)
[6eab7e9]674 ensureColumn('ap_outbox', 'away_until', 'INTEGER'); // FEP-633c 3.6.1 shaer:away + endTime (epoch ms)
[e62f65d]675 ensureColumn('ap_mentions', 'wave', 'INTEGER'); // inbound guardian wave
[af5b79b]676 // FEP-633c §2.2: object hint that the author is a ward. Register-only for now;
677 // used later at reddings-boei / escalation routing.
678 ensureColumn('ap_timeline', 'has_guardians', 'INTEGER');
679 ensureColumn('ap_mentions', 'has_guardians', 'INTEGER');
[d9ad6c5]680 // Berichten and de Krant render a post the same way, so a mention or a reply
681 // needs the same trimmings a timeline row already has: custom emojis, the
682 // media the note carried, and the quote / link-preview card.
683 ensureColumn('ap_mentions', 'emoji_json', 'TEXT'); // FEP-9098, in the content
684 ensureColumn('ap_mentions', 'actor_emoji_json', 'TEXT'); // FEP-9098, in the display name
685 ensureColumn('ap_mentions', 'media_json', 'TEXT');
686 ensureColumn('ap_mentions', 'quote_json', 'TEXT'); // FEP-044f quoted post
687 ensureColumn('ap_mentions', 'embed_json', 'TEXT'); // external link preview
688 ensureColumn('ap_interactions', 'media_json', 'TEXT');
689 ensureColumn('ap_interactions', 'quote_json', 'TEXT');
690 ensureColumn('ap_interactions', 'embed_json', 'TEXT');
[7922694]691 ensureColumn('ap_followers', 'name', 'TEXT'); // cached display name (shaer-aa3)
692 ensureColumn('ap_followers', 'handle', 'TEXT'); // @user@host
693 ensureColumn('ap_followers', 'icon', 'TEXT'); // avatar URL
[7bc636b]694}
695
696function ensureColumn(table, column, definition) {
697 try {
698 db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
699 console.log(`🔧 Added column ${table}.${column}`);
700 } catch (e) {
701 // "duplicate column name" → already there. Anything else, surface it.
702 if (!/duplicate column/i.test(e.message)) {
703 console.error(`❌ ensureColumn(${table}.${column}):`, e.message);
704 }
705 }
706}
707
708export default db;
Note: See TracBrowser for help on using the repository browser.