source: Klonkt/src/config/database.js@ 65abc85

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

Gated settings federeren: guardians beslissen samen, ook van een andere server

Ik had de knop alleen voor het co-located geval gebouwd, en dat is precies het
uitzonderingsgeval. In de echte opstelling staat de ward op de ene server en zijn
drie guardians op twee andere: er was dus nergens een knop. Dat botst met onze
eigen regel dat co-locatie een optimalisatie is en nooit de aanname.

Nu volgens FEP-633c 5.6 (deze week aan de spec toegevoegd): een guardian stelt
een wijziging voor met een Offer van een shaer:GatedSetting aan de server van de
WARD; de andere guardians antwoorden met Accept/Reject; de server van de ward
telt en handhaaft, want die serveert de feed. Co-locatie neemt dezelfde weg: ook
daar wordt voorgesteld en geteld, anders zou een guardian naast de deur meer te
zeggen hebben dan een op afstand.

De tally is een 3.5-beslissing en staat als pure functie apart: gesnapshotte set,
strikte meerderheid, venster van een dag. Omkeerbaar, dus race naar de drempel in
BEIDE richtingen (settelt ook zodra een meerderheid onhaalbaar is) en faalt dicht
op de deadline. Een Reject is een stem voor de andere waarde, geen schouderophalen.

New file:
src/services/guardianship/gated.js

  • tallyGatedSetting (puur), thresholdFor, featureColumn (onbekende features geweigerd i.p.v. geraden), recordGatedVote, en de Offer-vorm

test/gated-settings.test.js

  • 9 tests: drempel, vroeg settelen in beide richtingen, dicht op de deadline, vreemden tellen niet mee, geen guardians = niets toegekend, van gedachten veranderen vervangt je stem, en een onbekende feature raakt geen kolom

Changed files:
src/config/database.js

  • ap_gated_offers + ap_gated_votes

src/services/guardianship/handshake.js

  • inbox: Offer(shaer:GatedSetting) en Accept/Reject erop, met de stem van de voorsteller meegeteld (one-step-clausule)

src/services/guardianship/index.js

  • gated geexporteerd

src/routes/guardian.js

  • de knop stuurt een voorstel, lokaal en remote langs dezelfde weg

src/assets/js/guardian.js

  • knop bij ELKE ward, ook remote; toont 'wacht op de andere guardians'

src/services/i18n.js

  • embeds_propose / embeds_waiting in nl, en, de

remarks: 228 tests groen (was 219).

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

  • Property mode set to 100644
File size: 33.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 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 -- 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 );
516 CREATE TABLE IF NOT EXISTS ap_gated_votes (
517 slug TEXT NOT NULL, -- the WARD, on this server
518 feature TEXT NOT NULL, -- e.g. 'shaer:externalEmbeds'
519 guardian_uri TEXT NOT NULL, -- who answered (must be a committed guardian)
520 value INTEGER NOT NULL, -- the value they voted for (0/1)
521 opened_at DATETIME NOT NULL, -- when this decision opened (the window start)
522 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
523 PRIMARY KEY (slug, feature, guardian_uri)
524 );
525 CREATE TABLE IF NOT EXISTS ap_delivery (
526 id INTEGER PRIMARY KEY AUTOINCREMENT,
527 slug TEXT NOT NULL, -- our site/actor that signs the delivery
528 inbox TEXT NOT NULL, -- recipient inbox URL
529 body TEXT NOT NULL, -- the activity JSON to POST
530 attempts INTEGER NOT NULL DEFAULT 0,
531 next_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
532 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
533 );
534 CREATE INDEX IF NOT EXISTS idx_ap_delivery_due ON ap_delivery(next_at);
535 CREATE TABLE IF NOT EXISTS poll_votes (
536 id INTEGER PRIMARY KEY AUTOINCREMENT,
537 post_id INTEGER NOT NULL, -- our local poll post (posts.id)
538 actor_uri TEXT NOT NULL, -- the remote voter's AP actor URI
539 choice TEXT NOT NULL, -- the chosen option's name (matches poll_json options[].name)
540 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
541 UNIQUE(post_id, actor_uri, choice)
542 );
543 CREATE INDEX IF NOT EXISTS idx_poll_votes_post ON poll_votes(post_id);
544 CREATE TABLE IF NOT EXISTS ap_mentions (
545 id INTEGER PRIMARY KEY AUTOINCREMENT,
546 slug TEXT NOT NULL, -- our mentioned site/actor
547 object_uri TEXT NOT NULL, -- the remote note that mentions us
548 note_url TEXT, -- its human URL (open/interact)
549 actor_uri TEXT, actor_name TEXT, actor_handle TEXT, actor_icon TEXT, actor_url TEXT,
550 content TEXT, -- sanitized HTML snippet of the mentioning note
551 published TEXT,
552 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
553 UNIQUE(slug, object_uri)
554 );
555 CREATE INDEX IF NOT EXISTS idx_ap_mentions_slug ON ap_mentions(slug, created_at);
556 CREATE TABLE IF NOT EXISTS ap_reports (
557 id INTEGER PRIMARY KEY AUTOINCREMENT,
558 slug TEXT NOT NULL, -- our site the report is about (its owner moderates)
559 actor_uri TEXT, -- the reporter's actor URI
560 actor_name TEXT, actor_handle TEXT, actor_icon TEXT,
561 content TEXT, -- the reason (plain text)
562 objects TEXT, -- JSON array of reported object URIs (our actor + statuses)
563 seen INTEGER DEFAULT 0,
564 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
565 );
566 CREATE INDEX IF NOT EXISTS idx_ap_reports_slug ON ap_reports(slug, created_at);
567 `);
568 // "Feature" a followed account: its posts show in the local Cirkel.
569 ensureColumn('ap_following', 'auto_boost', 'INTEGER DEFAULT 0');
570 // A timeline post you boosted (๐Ÿ”) โ€” also shown in the Cirkel (mixed by date).
571 ensureColumn('ap_timeline', 'boosted', 'INTEGER DEFAULT 0');
572 ensureColumn('ap_timeline', 'liked', 'INTEGER DEFAULT 0'); // a feed post you liked (โญ) โ†’ toggle
573 ensureColumn('ap_timeline', 'nsfw', 'INTEGER DEFAULT 0'); // remote sensitive post โ†’ blur in the Cirkel
574 ensureColumn('ap_timeline', 'cw', 'TEXT'); // remote content-warning text
575 ensureColumn('ap_timeline', 'emoji_json', 'TEXT'); // FEP-9098 custom emoji Emoji tags from the inbound note, served back as `tag`
576 ensureColumn('ap_timeline', 'link_json', 'TEXT'); // FEP-e232 object-link (quote/ref) tags from the inbound note, served back as `tag`
577 ensureColumn('ap_timeline', 'quote_json', 'TEXT'); // FEP-044f resolved quoted-post snapshot (author + content), for the embedded quote card
578 // FEP-044f: the fediverse object THIS post quotes, resolved once at publish
579 // time so buildNote (sync, also used by the outbox) needs no network.
580 ensureColumn('posts', 'quote_uri', 'TEXT'); // the quoted object's id
581 ensureColumn('posts', 'quote_actor', 'TEXT'); // its author, so we can address them
582 ensureColumn('ap_timeline', 'embed_json', 'TEXT'); // resolved EXTERNAL embed (oEmbed/provider), thumbnail-only; gated per site (sites.external_embeds)
583 ensureColumn('ap_timeline', 'author_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the author's display name (shaer:author.emojis)
584 ensureColumn('ap_timeline', 'reblog_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the booster's display name (shaer:booster.emojis)
585 ensureColumn('ap_timeline', 'reblog_name', 'TEXT'); // a followed account boosted this โ†’ "X boosted"
586 ensureColumn('ap_timeline', 'reblog_handle', 'TEXT'); // the booster's @handle
587 ensureColumn('ap_timeline', 'reblog_icon', 'TEXT'); // the booster's avatar
588 ensureColumn('ap_timeline', 'poll_json', 'TEXT'); // a Question (poll): {multiple,options[{name,count}],endTime,closed,voters,voted}
589
590 // Delivery health per follower โ†’ surface dead accounts for manual cleanup.
591 ensureColumn('ap_followers', 'last_delivery_at', 'DATETIME'); // last SUCCESSFUL delivery to this follower's inbox
592 ensureColumn('ap_followers', 'last_error_at', 'DATETIME'); // last time a delivery to it gave up (max retries)
593
594 // ActivityPub `source` model: content_rendered = baked display HTML (#hashtags / URLs /
595 // @mentions linkified once at save). `content` stays the raw source used for editing and
596 // re-rendering. NULL on old posts โ†’ the render route bakes on the fly as a fallback.
597 ensureColumn('posts', 'content_rendered', 'TEXT');
598
599 // AP addressing of an incoming interaction: 'public' | 'unlisted' | 'followers' | 'direct',
600 // derived from the note's to/cc at ingest. The public post page only renders public/unlisted
601 // replies; followers/direct replies surface in notifications (and later Messages) with post
602 // context instead. Existing rows default to 'public' (historically almost all were).
603 ensureColumn('ap_interactions', 'visibility', "TEXT DEFAULT 'public'");
604 ensureColumn('ap_interactions', 'emoji_json', 'TEXT'); // FEP-9098 custom emojis in a reply's content (messages + thread)
605 ensureColumn('ap_interactions', 'actor_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the reply author's display name
606 // Rich replies: the reply's language (BCP47 code) โ†’ contentMap on the outgoing Note.
607 ensureColumn('ap_outbox', 'language', 'TEXT');
608 // Rich replies: JSON array [{url, mediaType, name}] โ†’ `attachment` on the Note.
609 ensureColumn('ap_outbox', 'attachments', 'TEXT');
610 ensureColumn('posts', 'ap_visibility', 'TEXT'); // public|quiet|friends|direct (C2S addressing, shaer-60b)
611 ensureColumn('posts', 'paid', 'INTEGER DEFAULT 0'); // paid post (klonkt-demo-aki)
612 ensureColumn('posts', 'paid_min_cents', 'INTEGER'); // required support; null = owner default
613 ensureColumn('paid_patreon', 'patreon_url', 'TEXT'); // owner's public Patreon page โ†’ "Word supporter" link (klonkt-demo-aki)
614 ensureColumn('ap_outbox', 'visibility', 'TEXT'); // 'direct' = private mention, never Public (shaer-tqc)
615 ensureColumn('ap_outbox', 'to_actors', 'TEXT'); // JSON array of recipient actor URIs for direct notes
616 ensureColumn('ap_outbox', 'help_request', 'INTEGER'); // FEP-633c shaer:helpRequest (ward's call for help)
617 ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
618 ensureColumn('ap_outbox', 'wave', 'INTEGER'); // FEP-633c shaer:wave (guardian -> ward nudge)
619 ensureColumn('ap_mentions', 'wave', 'INTEGER'); // inbound guardian wave
620 // FEP-633c ยง2.2: object hint that the author is a ward. Register-only for now;
621 // used later at reddings-boei / escalation routing.
622 ensureColumn('ap_timeline', 'has_guardians', 'INTEGER');
623 ensureColumn('ap_mentions', 'has_guardians', 'INTEGER');
624 ensureColumn('ap_followers', 'name', 'TEXT'); // cached display name (shaer-aa3)
625 ensureColumn('ap_followers', 'handle', 'TEXT'); // @user@host
626 ensureColumn('ap_followers', 'icon', 'TEXT'); // avatar URL
627}
628
629function ensureColumn(table, column, definition) {
630 try {
631 db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
632 console.log(`๐Ÿ”ง Added column ${table}.${column}`);
633 } catch (e) {
634 // "duplicate column name" โ†’ already there. Anything else, surface it.
635 if (!/duplicate column/i.test(e.message)) {
636 console.error(`โŒ ensureColumn(${table}.${column}):`, e.message);
637 }
638 }
639}
640
641export default db;
Note: See TracBrowser for help on using the repository browser.