source: Klonkt/src/config/database.js@ 6d5ce0c

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

Afspelen in de app als tweede gated feature, en het gat in de gate

Bij het uitzoeken van de YouTube-vraag bleek de gate lek. De web-Krant bouwt de
speler uit de inhoud van de post via timelineEmbedHtml, en dat pad raakte
gateEmbeds nooit. Een ward wiens guardians niets hadden toegestaan kreeg dus de
volledige YouTube-speler op het web, terwijl de app niets liet zien: het zware
ding open, het lichte dicht. Precies omgekeerd.

Nu zijn het twee besluiten, want het zijn twee dingen. Zien dat er een filmpje
is, is niet hetzelfde als het scherm afstaan aan de motor van een derde partij,
compleet met eindscherm en volgende-video. shaer:externalEmbeds houdt de kaart,
shaer:externalPlayback de speler, allebei standaard uit voor een ward, en
afspelen vereist de kaart: je kunt niet spelen wat je niet mag zien.

En het antwoord op Robins vraag over de links: die vallen er ook onder. De gate
verborg tot nu toe alleen het plaatje terwijl de kale link eronder gewoon
aantikbaar bleef, dus de deur stond open met een doek eroverheen. Staat de gate
dicht, dan toont de kaart zich nog wel maar is hij geen deur meer.

De server bepaalt wat gespeeld mag worden, niet de client: hij levert
shaer:playerUrl mee, alleen bij een open gate en alleen in de privacy-variant
(youtube-nocookie met rel=0, of de eigen speler van de PeerTube-instance). De
app houdt zo geen lijst van hosts bij; hij speelt wat hij krijgt aangereikt.

Changed files:
src/config/database.js

  • kolom sites.external_playback

src/services/guardianship/notes.js

  • externalPlaybackAllowed naast externalEmbedsAllowed

src/services/guardianship/gated.js

  • shaer:externalPlayback in de feature-tabel

src/services/ActivityPubService.js

  • timelineEmbed voegt shaer:playerUrl toe als afspelen mag; playerUrlFor kent alleen privacy-varianten en weigert de rest

src/routes/activitypub.js

  • shaer:capabilities op de owner-only inbox-read: wat mag dit account
  • de embed draagt de speler-URL alleen bij een open playback-gate

src/routes/posts.js

  • het gat gedicht: de speler-iframe op de web-Krant valt nu onder de gate

src/routes/guardian.js

  • de voorstel-route is feature-bewust; het lokale pad stuurt nu ook door

src/assets/js/guardian.js

  • tweede knop in het paneel, alleen zichtbaar als de kaart al aan staat

src/services/i18n.js

  • de labels in nl, en, de

test/gated-settings.test.js

  • drie tests: de speler-URL rijdt alleen mee bij een open gate, een pagina die we niet framen blijft een thumbnail, en afspelen vereist de kaart

remarks: 280 tests groen. Niets geforceerd: beide gates staan standaard uit
voor een ward en twee van de drie guardians moeten nog steeds akkoord gaan.

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

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