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

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

Beschikbaarheid van guardians (FEP-633c 3.6): away, dormant, lapse

De Klonkt-kant van het beschikbaarheidsvoorstel, nagemaakt zoals eerst in de
daemon gevalideerd (shaer-8z7): dezelfde toestanden, dezelfde regels, dezelfde
weigeringen. De spiegel-tests dragen dezelfde namen als de daemon-tests, zodat
drift tussen de twee backends opvalt als een falende test met dezelfde woorden.

De kern is guardianship/availability.js: drie toestanden per (ward, guardian),
met als regel boven alles dat een antwoord alles herstelt, tot en met een
lopende lapse. Elke geverifieerde inbox-activiteit en elke C2S-handeling van
een guardian herstelt hem en annuleert een lapse tegen hem, nog voor er naar de
activiteit gekeken wordt. Bewust achter de handtekening-poort: een ongeverifieerde
bewering oma te zijn mag oma niet wakker maken.

Afwezig komt binnen over beide wegen: S2S als directe note met shaer:away en
endTime van een guardian elders (het gewone geval), en C2S als een guardian
hier zich afmeldt; die note draagt de marker mee naar wards elders en wordt
voor wards op deze instance direct toegepast, want een lokale inbox ontvangt
zijn eigen bezorging niet. Zonder (toekomstig) einde faalt het luid met 400,
precies zoals de daemon weigert.

Slapend volgt alleen uit onbeantwoorde direct geadresseerde verzoeken; de
follow-gating registreert die nu als bewijs. De markering notificeert verplicht
via protocol en de 6-handle, eenmalig op de overgang, centraal bedraad zodat
elke plek waar een promotie kan gebeuren hetzelfde notificeert.

De drempel van 3.5 rekent voortaan over de beschikbare set: de follow-quorums
en de gated settings allebei. De test die het waarom draagt: vijf guardians van
wie twee weg zijn gaven een drempel van drie die de twee levenden nooit haalden;
over de beschikbare set beslissen zij weer.

De lapse loopt over dezelfde draden als de gated settings: een Offer van
shaer:Lapse opent op de server van het kind, Accept/Reject stemt, het venster
loopt altijd vol, en de voltooiing verwijdert de relatie met de
nooit-leeg-grens uit 3.4 als tweede slot eronder. De offers-queue draagt de
lopende lapses en de nieuwe owner-only guardians-queue de beschikbaarheid, in
precies de vorm die de daemon serveert, dus de Shaer-apps van gisteren werken
zonder wijziging.

Changed files:
src/config/database.js

  • tabellen ap_guardian_attention, ap_attention_requests, ap_lapses
  • kolom ap_outbox.away_until

src/services/guardianship/handshake.js

  • Offer van shaer:Lapse (S2S en C2S), lapse-stemmen op Accept/Reject, one-answer op elke C2S-handeling

src/services/guardianship/gated.js

  • tally en voortgang over de beschikbare set; een stem is een antwoord

src/services/guardianship/notes.js

  • awayProps: shaer:away plus endTime op de uitgaande directe note

src/services/guardianship/delivery.js

  • away_until door het directe pad heen

src/services/guardianship/queues.js

  • guardiansCollection; offersCollection draagt de lapses

src/services/guardianship/index.js

  • exports

src/services/ActivityPubService.js

  • one-answer achter de handtekening-poort
  • away-ingest op het mention-pad en het C2S-directe pad
  • dormancy-bewijs op de follow-gating; quorum over de beschikbare set
  • de notificatieplicht van 3.6.2, een keer bedraad
  • buildReplyNote draagt awayProps

src/routes/activitypub.js

  • owner-only route /queues/guardians

src/routes/guardian.js

  • dashboard-besluit is een antwoord; quorum over de beschikbare set

src/services/guardianship/relations.js

  • guardians-queue aangekondigd in shaer:queues

test/activitypub-as2.test.js

  • guardians toegevoegd aan de queue-sleutels

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

  • de toestandsmachine, de lapse en de endTime-parser

test/availability.test.js

  • veertien spiegel-tests van de daemon, tot en met de volle lapse-flow over de S2S-draad en het vijf-guardians-rekenvoorbeeld

remarks: de PWA toont de beschikbaarheid nog niet (chips in het paneel per
kind en een lapse-kaart komen apart); de echte kruis-implementatie-testbank
blijft open op shaer-6d9. Klonkt heeft geen pinbare klok zoals de daemon; de
tests dateren bewijs terug in plaats van de tijd vooruit te zetten, en dat
staat er als kanttekening bij. Niet uitgerold.

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

  • Property mode set to 100644
File size: 35.8 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 );
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 );
[6eab7e9]525 -- Guardian availability (FEP-633c 3.6): one guardian's attention as seen
526 -- from one ward on this server. Never public; the ward reads it via the
527 -- owner-only guardians queue. One rule above all: one answer restores
528 -- everything, so every row here is one answer away from disappearing.
529 CREATE TABLE IF NOT EXISTS ap_guardian_attention (
530 ward_slug TEXT NOT NULL,
531 guardian_uri TEXT NOT NULL,
532 state TEXT NOT NULL DEFAULT 'active', -- 'active' | 'away' | 'dormant'
533 away_until INTEGER, -- epoch ms while declared away
534 PRIMARY KEY (ward_slug, guardian_uri)
535 );
536 -- The ONLY admissible dormancy evidence (3.6.2): directly addressed
537 -- requests that went unanswered. Calendar time alone never counts.
538 CREATE TABLE IF NOT EXISTS ap_attention_requests (
539 ward_slug TEXT NOT NULL,
540 guardian_uri TEXT NOT NULL,
541 request_id TEXT NOT NULL,
542 asked_at INTEGER NOT NULL, -- epoch ms
543 PRIMARY KEY (ward_slug, guardian_uri, request_id)
544 );
545 -- A lapse (3.6.3): the available co-guardians deciding to release a
546 -- dormant one. Irreversible, so the window always runs in full; any sign
547 -- of life from the target cancels it outright.
548 CREATE TABLE IF NOT EXISTS ap_lapses (
549 id TEXT PRIMARY KEY,
550 ward_slug TEXT NOT NULL,
551 ward_uri TEXT NOT NULL,
552 target_uri TEXT NOT NULL,
553 opened_by TEXT NOT NULL,
554 set_json TEXT NOT NULL, -- the available set at open, target excluded
555 accepts_json TEXT NOT NULL DEFAULT '[]',
556 rejects_json TEXT NOT NULL DEFAULT '[]',
557 opened_at INTEGER NOT NULL, -- epoch ms
558 window_ms INTEGER NOT NULL,
559 cancelled INTEGER NOT NULL DEFAULT 0,
560 applied INTEGER NOT NULL DEFAULT 0,
561 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
562 );
[5a6a457]563 CREATE TABLE IF NOT EXISTS ap_delivery (
564 id INTEGER PRIMARY KEY AUTOINCREMENT,
565 slug TEXT NOT NULL, -- our site/actor that signs the delivery
566 inbox TEXT NOT NULL, -- recipient inbox URL
567 body TEXT NOT NULL, -- the activity JSON to POST
568 attempts INTEGER NOT NULL DEFAULT 0,
569 next_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
570 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
571 );
572 CREATE INDEX IF NOT EXISTS idx_ap_delivery_due ON ap_delivery(next_at);
[0403187]573 CREATE TABLE IF NOT EXISTS poll_votes (
574 id INTEGER PRIMARY KEY AUTOINCREMENT,
575 post_id INTEGER NOT NULL, -- our local poll post (posts.id)
576 actor_uri TEXT NOT NULL, -- the remote voter's AP actor URI
577 choice TEXT NOT NULL, -- the chosen option's name (matches poll_json options[].name)
578 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
579 UNIQUE(post_id, actor_uri, choice)
580 );
581 CREATE INDEX IF NOT EXISTS idx_poll_votes_post ON poll_votes(post_id);
[fe97cc3]582 CREATE TABLE IF NOT EXISTS ap_mentions (
583 id INTEGER PRIMARY KEY AUTOINCREMENT,
584 slug TEXT NOT NULL, -- our mentioned site/actor
585 object_uri TEXT NOT NULL, -- the remote note that mentions us
586 note_url TEXT, -- its human URL (open/interact)
587 actor_uri TEXT, actor_name TEXT, actor_handle TEXT, actor_icon TEXT, actor_url TEXT,
588 content TEXT, -- sanitized HTML snippet of the mentioning note
589 published TEXT,
590 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
591 UNIQUE(slug, object_uri)
592 );
593 CREATE INDEX IF NOT EXISTS idx_ap_mentions_slug ON ap_mentions(slug, created_at);
[737ea05]594 CREATE TABLE IF NOT EXISTS ap_reports (
595 id INTEGER PRIMARY KEY AUTOINCREMENT,
596 slug TEXT NOT NULL, -- our site the report is about (its owner moderates)
597 actor_uri TEXT, -- the reporter's actor URI
598 actor_name TEXT, actor_handle TEXT, actor_icon TEXT,
599 content TEXT, -- the reason (plain text)
600 objects TEXT, -- JSON array of reported object URIs (our actor + statuses)
601 seen INTEGER DEFAULT 0,
602 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
603 );
604 CREATE INDEX IF NOT EXISTS idx_ap_reports_slug ON ap_reports(slug, created_at);
[914eb9f]605 `);
[5045c30]606 // "Feature" a followed account: its posts show in the local Cirkel.
[f278df9]607 ensureColumn('ap_following', 'auto_boost', 'INTEGER DEFAULT 0');
[5045c30]608 // A timeline post you boosted (🔁) — also shown in the Cirkel (mixed by date).
609 ensureColumn('ap_timeline', 'boosted', 'INTEGER DEFAULT 0');
[9d34855]610 ensureColumn('ap_timeline', 'liked', 'INTEGER DEFAULT 0'); // a feed post you liked (⭐) → toggle
[b7d4458]611 ensureColumn('ap_timeline', 'nsfw', 'INTEGER DEFAULT 0'); // remote sensitive post → blur in the Cirkel
612 ensureColumn('ap_timeline', 'cw', 'TEXT'); // remote content-warning text
[97bacf2]613 ensureColumn('ap_timeline', 'emoji_json', 'TEXT'); // FEP-9098 custom emoji Emoji tags from the inbound note, served back as `tag`
[eb36688]614 ensureColumn('ap_timeline', 'link_json', 'TEXT'); // FEP-e232 object-link (quote/ref) tags from the inbound note, served back as `tag`
[6fd0e20]615 ensureColumn('ap_timeline', 'quote_json', 'TEXT'); // FEP-044f resolved quoted-post snapshot (author + content), for the embedded quote card
[b258a79]616 // FEP-044f: the fediverse object THIS post quotes, resolved once at publish
617 // time so buildNote (sync, also used by the outbox) needs no network.
618 ensureColumn('posts', 'quote_uri', 'TEXT'); // the quoted object's id
619 ensureColumn('posts', 'quote_actor', 'TEXT'); // its author, so we can address them
620 ensureColumn('ap_timeline', 'embed_json', 'TEXT'); // resolved EXTERNAL embed (oEmbed/provider), thumbnail-only; gated per site (sites.external_embeds)
[a677616]621 ensureColumn('ap_timeline', 'author_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the author's display name (shaer:author.emojis)
[f3caf19]622 ensureColumn('ap_timeline', 'reblog_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the booster's display name (shaer:booster.emojis)
[c6cdce6]623 ensureColumn('ap_timeline', 'reblog_name', 'TEXT'); // a followed account boosted this → "X boosted"
624 ensureColumn('ap_timeline', 'reblog_handle', 'TEXT'); // the booster's @handle
625 ensureColumn('ap_timeline', 'reblog_icon', 'TEXT'); // the booster's avatar
[6053c6c]626 ensureColumn('ap_timeline', 'poll_json', 'TEXT'); // a Question (poll): {multiple,options[{name,count}],endTime,closed,voters,voted}
[8878814]627
628 // Delivery health per follower → surface dead accounts for manual cleanup.
629 ensureColumn('ap_followers', 'last_delivery_at', 'DATETIME'); // last SUCCESSFUL delivery to this follower's inbox
630 ensureColumn('ap_followers', 'last_error_at', 'DATETIME'); // last time a delivery to it gave up (max retries)
[2d6a9c3]631
632 // ActivityPub `source` model: content_rendered = baked display HTML (#hashtags / URLs /
633 // @mentions linkified once at save). `content` stays the raw source used for editing and
634 // re-rendering. NULL on old posts → the render route bakes on the fly as a fallback.
635 ensureColumn('posts', 'content_rendered', 'TEXT');
[3778ddb]636
637 // AP addressing of an incoming interaction: 'public' | 'unlisted' | 'followers' | 'direct',
638 // derived from the note's to/cc at ingest. The public post page only renders public/unlisted
639 // replies; followers/direct replies surface in notifications (and later Messages) with post
640 // context instead. Existing rows default to 'public' (historically almost all were).
641 ensureColumn('ap_interactions', 'visibility', "TEXT DEFAULT 'public'");
[ef1853c]642 ensureColumn('ap_interactions', 'emoji_json', 'TEXT'); // FEP-9098 custom emojis in a reply's content (messages + thread)
643 ensureColumn('ap_interactions', 'actor_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the reply author's display name
[33e1dbd]644 // Rich replies: the reply's language (BCP47 code) → contentMap on the outgoing Note.
645 ensureColumn('ap_outbox', 'language', 'TEXT');
[feced2c]646 // Rich replies: JSON array [{url, mediaType, name}] → `attachment` on the Note.
647 ensureColumn('ap_outbox', 'attachments', 'TEXT');
[81b2e1e]648 ensureColumn('posts', 'ap_visibility', 'TEXT'); // public|quiet|friends|direct (C2S addressing, shaer-60b)
[928d1c7]649 ensureColumn('posts', 'paid', 'INTEGER DEFAULT 0'); // paid post (klonkt-demo-aki)
650 ensureColumn('posts', 'paid_min_cents', 'INTEGER'); // required support; null = owner default
[c3d12a6]651 ensureColumn('paid_patreon', 'patreon_url', 'TEXT'); // owner's public Patreon page → "Word supporter" link (klonkt-demo-aki)
[024f4f8]652 ensureColumn('ap_outbox', 'visibility', 'TEXT'); // 'direct' = private mention, never Public (shaer-tqc)
653 ensureColumn('ap_outbox', 'to_actors', 'TEXT'); // JSON array of recipient actor URIs for direct notes
[155c24e]654 ensureColumn('ap_outbox', 'help_request', 'INTEGER'); // FEP-633c shaer:helpRequest (ward's call for help)
[6b5d7da]655 ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
[e62f65d]656 ensureColumn('ap_outbox', 'wave', 'INTEGER'); // FEP-633c shaer:wave (guardian -> ward nudge)
[6eab7e9]657 ensureColumn('ap_outbox', 'away_until', 'INTEGER'); // FEP-633c 3.6.1 shaer:away + endTime (epoch ms)
[e62f65d]658 ensureColumn('ap_mentions', 'wave', 'INTEGER'); // inbound guardian wave
[af5b79b]659 // FEP-633c §2.2: object hint that the author is a ward. Register-only for now;
660 // used later at reddings-boei / escalation routing.
661 ensureColumn('ap_timeline', 'has_guardians', 'INTEGER');
662 ensureColumn('ap_mentions', 'has_guardians', 'INTEGER');
[d9ad6c5]663 // Berichten and de Krant render a post the same way, so a mention or a reply
664 // needs the same trimmings a timeline row already has: custom emojis, the
665 // media the note carried, and the quote / link-preview card.
666 ensureColumn('ap_mentions', 'emoji_json', 'TEXT'); // FEP-9098, in the content
667 ensureColumn('ap_mentions', 'actor_emoji_json', 'TEXT'); // FEP-9098, in the display name
668 ensureColumn('ap_mentions', 'media_json', 'TEXT');
669 ensureColumn('ap_mentions', 'quote_json', 'TEXT'); // FEP-044f quoted post
670 ensureColumn('ap_mentions', 'embed_json', 'TEXT'); // external link preview
671 ensureColumn('ap_interactions', 'media_json', 'TEXT');
672 ensureColumn('ap_interactions', 'quote_json', 'TEXT');
673 ensureColumn('ap_interactions', 'embed_json', 'TEXT');
[7922694]674 ensureColumn('ap_followers', 'name', 'TEXT'); // cached display name (shaer-aa3)
675 ensureColumn('ap_followers', 'handle', 'TEXT'); // @user@host
676 ensureColumn('ap_followers', 'icon', 'TEXT'); // avatar URL
[7bc636b]677}
678
679function ensureColumn(table, column, definition) {
680 try {
681 db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
682 console.log(`🔧 Added column ${table}.${column}`);
683 } catch (e) {
684 // "duplicate column name" → already there. Anything else, surface it.
685 if (!/duplicate column/i.test(e.message)) {
686 console.error(`❌ ensureColumn(${table}.${column}):`, e.message);
687 }
688 }
689}
690
691export default db;
Note: See TracBrowser for help on using the repository browser.