eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "crypto/ed25519"
5 "crypto/rand"
6 "crypto/sha256"
7 "crypto/subtle"
8 "database/sql"
9 "encoding/base64"
10 "encoding/hex"
11 "errors"
12 "log"
13 "path/filepath"
14 "strconv"
15 "strings"
16 "time"
17
18 _ "modernc.org/sqlite"
19)
20
21// defaultGroupName is used until an admin sets one. "lchat" is the
22// implementation name, never a public one — an unnamed space presents as the
23// product, Eleven (PWA install name, page title, native add-chat screens).
24const defaultGroupName = "Eleven"
25
26// getGroupName returns the chat's display name (used as the PWA/app name, page
27// title, and chat header), falling back to a default until an admin sets one.
28func (db *DB) getGroupName() string {
29 v, ok, _ := db.getConfig("group_name")
30 if !ok || strings.TrimSpace(v) == "" {
31 return defaultGroupName
32 }
33 return v
34}
35
36func (db *DB) setGroupName(name string) error {
37 return db.setConfig("group_name", name)
38}
39
40// groupNameSet reports whether an admin explicitly named the instance (vs the
41// compiled-in default) — the setup conversation asks fresh instead of offering
42// a default to keep. A sealed instance is always named: the name lives in the
43// branding blob and the plaintext column is deliberately empty.
44func (db *DB) groupNameSet() bool {
45 if db.brandingSealed() {
46 return true
47 }
48 v, ok, _ := db.getConfig("group_name")
49 return ok && strings.TrimSpace(v) != ""
50}
51
52// getAdminName / getAdminEmail return the optional administrator identity an
53// admin may publish to members (who runs this space + how to reach them). Shown
54// by Eleven's guided tour and answers. Plaintext branding like the group name —
55// admin-authored, never user content — and empty until set. Served only to
56// authenticated members (see getGroup), not on the public onboarding read.
57func (db *DB) getAdminName() string { v, _, _ := db.getConfig("admin_name"); return v }
58func (db *DB) getAdminEmail() string { v, _, _ := db.getConfig("admin_email"); return v }
59
60func (db *DB) setAdminName(v string) error { return db.setConfig("admin_name", v) }
61func (db *DB) setAdminEmail(v string) error { return db.setConfig("admin_email", v) }
62
63// getGroupDescription / setGroupDescription hold the space's free-text
64// description. Plaintext like the admin name/email above — admin-authored
65// operator metadata, never user content — so the keyless admin console can
66// edit it and the join card can show an invitee what they're joining. Served
67// to members and to valid invite holders, not on the public onboarding read.
68func (db *DB) getGroupDescription() string {
69 v, _, _ := db.getConfig("group_description")
70 return v
71}
72func (db *DB) setGroupDescription(v string) error {
73 return db.setConfig("group_description", v)
74}
75
76// setupComplete gates the first-run Eleven setup conversation: false until the
77// admin finishes (or dismisses) it. See docs/conversational-onboarding.md.
78func (db *DB) setupComplete() bool {
79 v, _, _ := db.getConfig("setup_complete")
80 return v == "1"
81}
82func (db *DB) setSetupComplete(on bool) error { return db.setConfig("setup_complete", boolStr(on)) }
83
84// getInstanceAvatar returns the admin-set instance branding photo (bytes + MIME +
85// an updated-at stamp for cache validation), or ok=false when none is set. Like the
86// group name, this is plaintext branding the server serves in the clear.
87func (db *DB) getInstanceAvatar() (data []byte, mime string, updatedAt int64, ok bool) {
88 err := db.sql.QueryRow("SELECT data, mime, updated_at FROM instance_avatar WHERE id = 1").
89 Scan(&data, &mime, &updatedAt)
90 if err == sql.ErrNoRows {
91 return nil, "", 0, false
92 }
93 if err != nil {
94 return nil, "", 0, false
95 }
96 return data, mime, updatedAt, true
97}
98
99// setInstanceAvatar stores (or replaces) the instance branding photo.
100func (db *DB) setInstanceAvatar(data []byte, mime string, updatedAt int64) error {
101 _, err := db.sql.Exec(
102 `INSERT INTO instance_avatar(id, data, mime, updated_at) VALUES(1, ?, ?, ?)
103 ON CONFLICT(id) DO UPDATE SET data = excluded.data, mime = excluded.mime, updated_at = excluded.updated_at`,
104 data, mime, updatedAt,
105 )
106 return err
107}
108
109// clearInstanceAvatar removes the branding photo (reverting to the default icon).
110func (db *DB) clearInstanceAvatar() error {
111 _, err := db.sql.Exec("DELETE FROM instance_avatar WHERE id = 1")
112 return err
113}
114
115// usersCanInvite reports whether ordinary members (not just the admin) may invite
116// new people. Off by default — the safest posture — and loosened only as an
117// explicit admin opt-in.
118func (db *DB) usersCanInvite() bool {
119 v, ok, _ := db.getConfig("user_invites_enabled")
120 return ok && v == "1"
121}
122
123func (db *DB) setUsersCanInvite(on bool) error {
124 v := "0"
125 if on {
126 v = "1"
127 }
128 return db.setConfig("user_invites_enabled", v)
129}
130
131// Per-instance feature gates: whether this space offers AI replies, bot
132// creation, and (member/mini) apps. All default ON — an absent key reads
133// true — so existing spaces keep today's behavior and turning one off is an
134// explicit admin opt-out. The server enforces each gate at its endpoints;
135// clients only hide the entry points.
136func (db *DB) aiEnabled() bool {
137 v, ok, _ := db.getConfig("ai_enabled")
138 return !ok || v == "1"
139}
140
141// aiEnabledExplicitly distinguishes "on because nothing was ever written"
142// (the compat default above) from "an admin explicitly said yes". The AI
143// opt-in signal (#858) keys on the latter: a fresh space's first "Bring
144// Eleven in" must count as consent even though the implicit default already
145// read true — and the default-on reading itself is harmless there, because
146// no sidecar exists until that explicit consent asks for one.
147func (db *DB) aiEnabledExplicitly() bool {
148 v, ok, _ := db.getConfig("ai_enabled")
149 return ok && v == "1"
150}
151func (db *DB) setAIEnabled(on bool) error { return db.setConfig("ai_enabled", boolStr(on)) }
152
153func (db *DB) botsEnabled() bool {
154 v, ok, _ := db.getConfig("bots_enabled")
155 return !ok || v == "1"
156}
157func (db *DB) setBotsEnabled(on bool) error { return db.setConfig("bots_enabled", boolStr(on)) }
158
159func (db *DB) appsEnabled() bool {
160 v, ok, _ := db.getConfig("apps_enabled")
161 return !ok || v == "1"
162}
163func (db *DB) setAppsEnabled(on bool) error { return db.setConfig("apps_enabled", boolStr(on)) }
164
165// memberAppsEnabled gates one slice of the apps feature: members building and
166// installing their OWN apps (App Studio deploys + bot builds). Narrower than
167// appsEnabled — a space can keep the built-in apps while switching off member
168// authoring. Like the others it only stops new use; apps already deployed keep
169// working.
170func (db *DB) memberAppsEnabled() bool {
171 v, ok, _ := db.getConfig("member_apps_enabled")
172 return !ok || v == "1"
173}
174func (db *DB) setMemberAppsEnabled(on bool) error {
175 return db.setConfig("member_apps_enabled", boolStr(on))
176}
177
178// spaceInstallsAdmins: whether adding an app FOR THE WHOLE SPACE is an
179// admin-only act (docs/space-apps.md § controls). Default OFF — any member,
180// attributed, mirroring group apps. Unlike the enable-* gates this is policy
181// the CLIENT enforces (the install registry is E2EE fold state the server
182// can't referee): served as page meta, honored by the sheet, @everyone, and
183// spaceAppsFor's read-time filter.
184func (db *DB) spaceInstallsAdmins() bool {
185 v, _, _ := db.getConfig("space_installs_admins")
186 return v == "1"
187}
188func (db *DB) setSpaceInstallsAdmins(on bool) error {
189 return db.setConfig("space_installs_admins", boolStr(on))
190}
191
192// Update settings: whether this instance follows its release channel (see
193// docs/server-releases.md). Unlike usersCanInvite, the booleans default ON —
194// an absent key reads true — so a fresh instance keeps itself current unless
195// the admin opts out. The box updater reads these via GET /api/update-prefs.
196func (db *DB) updateAuto() bool {
197 v, ok, _ := db.getConfig("update_auto")
198 return !ok || v == "1"
199}
200func (db *DB) setUpdateAuto(on bool) error { return db.setConfig("update_auto", boolStr(on)) }
201
202func (db *DB) updateDownload() bool {
203 v, ok, _ := db.getConfig("update_download")
204 return !ok || v == "1"
205}
206func (db *DB) setUpdateDownload(on bool) error { return db.setConfig("update_download", boolStr(on)) }
207
208// updateChannel is the release channel this instance tracks; anything unset or
209// unrecognised (e.g. a channel later renamed) falls back to stable.
210func (db *DB) updateChannel() string {
211 v, _, _ := db.getConfig("update_channel")
212 if !validChannel(v) {
213 return "stable"
214 }
215 return v
216}
217func (db *DB) setUpdateChannel(c string) error { return db.setConfig("update_channel", c) }
218
219// updatePin, when set, freezes this instance on one exact version — the
220// auto-updater leaves a pinned instance alone entirely (pin beats channel).
221// This is how a canary declares "I'm deliberately on this build": without it,
222// an hourly pass would stomp any manually-deployed version back to the channel
223// head. Empty = not pinned; an unparseable stored value reads as unpinned.
224func (db *DB) updatePin() string {
225 v, _, _ := db.getConfig("update_pin")
226 if !validVersion(v) {
227 return ""
228 }
229 return v
230}
231func (db *DB) setUpdatePin(v string) error { return db.setConfig("update_pin", v) }
232
233// updateMajor, when set, keeps this instance on one major version: it follows
234// its channel for minors and patches but a release whose semver crosses the
235// major is held, never auto-installed (majorHold, release.go). Empty = follow
236// everything; a garbled stored value reads as unset — like updatePin, it must
237// not freeze anything silently.
238func (db *DB) updateMajor() string {
239 v, _, _ := db.getConfig("update_major")
240 if !validMajor(v) {
241 return ""
242 }
243 return v
244}
245func (db *DB) setUpdateMajor(v string) error { return db.setConfig("update_major", v) }
246
247// charterNextDue is the sidecar-reported earliest next run across this space's
248// member-bot charters (unix seconds; 0 or absent = none). Charters themselves
249// are instance-key-encrypted blobs the server can't read (memberbots.go), so
250// the charter host reports this one timestamp so a hibernating instance can be
251// woken in time to run them (see getNextDue / postCharterNextDue). A config
252// key, not a table: it's a single space-wide value with last-write-wins
253// semantics, exactly the update_pin idiom — and no schema change at all.
254func (db *DB) charterNextDue() int64 {
255 v, _, _ := db.getConfig("charter_next_due")
256 n, _ := strconv.ParseInt(v, 10, 64)
257 if n < 0 {
258 return 0
259 }
260 return n
261}
262
263func (db *DB) setCharterNextDue(due int64) error {
264 return db.setConfig("charter_next_due", strconv.FormatInt(due, 10))
265}
266
267// DB wraps the SQLite connection. Note: the server never stores message
268// plaintext or any encryption key. Messages live here only as opaque
269// ciphertext blobs produced by the browser.
270type DB struct {
271 sql *sql.DB
272 // blobs holds attachment/photo bytes (see putBlob / the upload handler).
273 // It's the blobStore seam: the default is on-disk files next to the DB
274 // (<db>-blobs/), but nothing outside blobstore.go may assume that — the
275 // blobs *table* keeps the metadata (size, thread tag) either way.
276 blobs blobStore
277 // vapidPath is the sidecar keyfile (<db>.vapid) holding the Web Push VAPID
278 // pair — outside the DB so hibernation's S3 replica never carries a private
279 // key (see ensureVAPID in push.go).
280 vapidPath string
281}
282
283func openDB(path string) (*DB, error) {
284 d, err := sql.Open("sqlite", path)
285 if err != nil {
286 return nil, err
287 }
288 // One writer at a time keeps WAL happy and avoids "database is locked".
289 d.SetMaxOpenConns(1)
290 for _, p := range []string{
291 "PRAGMA journal_mode=WAL",
292 "PRAGMA busy_timeout=5000",
293 "PRAGMA synchronous=NORMAL",
294 "PRAGMA foreign_keys=ON",
295 } {
296 if _, err := d.Exec(p); err != nil {
297 return nil, err
298 }
299 }
300 disk, err := newDiskBlobStore(strings.TrimSuffix(path, filepath.Ext(path)) + "-blobs")
301 if err != nil {
302 return nil, err
303 }
304 // The blobStore seam's switch (#550 Phase 1): LCHAT_BLOB_S3_BUCKET set
305 // puts NEW attachment bytes in the object store, with the disk dir as the
306 // fallback for pre-flip blobs and the staging spool. Unset = today's
307 // on-disk layout, byte-identical behavior. LCHAT_BLOB_S3_MIRROR_* beside
308 // it adds the second copy on a second provider (federated blobs Phase A,
309 // #992 — the mirror sweep in blobmirror.go keeps it converged).
310 var blobs blobStore = disk
311 if cfg := s3BlobConfigFromEnv(); cfg != nil {
312 host := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
313 s3s, err := newS3BlobStore(cfg, disk.dir, disk, host)
314 if err != nil {
315 return nil, err
316 }
317 blobs = s3s
318 log.Printf("blobs: s3 store on (bucket %s, prefix %s)", cfg.Bucket, s3s.prefix)
319 if mcfg := s3BlobMirrorConfigFromEnv(); mcfg != nil {
320 mirror, err := newS3BlobStore(mcfg, disk.dir, nil, host)
321 if err != nil {
322 return nil, err
323 }
324 blobs = &mirroredBlobStore{primary: s3s, mirror: mirror}
325 log.Printf("blobs: mirror store on (bucket %s, prefix %s)", mcfg.Bucket, mirror.prefix)
326 }
327 } else if s3BlobMirrorConfigFromEnv() != nil {
328 // A mirror with no primary is a config mistake, not a mode — the
329 // disk layout's second copy is the fleet backup's job.
330 return nil, errors.New("LCHAT_BLOB_S3_MIRROR_* set without LCHAT_BLOB_S3_BUCKET")
331 }
332 db := &DB{sql: d, blobs: blobs,
333 vapidPath: strings.TrimSuffix(path, filepath.Ext(path)) + ".vapid"}
334 if err := db.migrate(); err != nil {
335 return nil, err
336 }
337 return db, nil
338}
339
340func (db *DB) migrate() error {
341 _, err := db.sql.Exec(`
342CREATE TABLE IF NOT EXISTS config (
343 key TEXT PRIMARY KEY,
344 value TEXT NOT NULL
345);
346CREATE TABLE IF NOT EXISTS messages (
347 id INTEGER PRIMARY KEY AUTOINCREMENT,
348 created_at INTEGER NOT NULL,
349 blob TEXT NOT NULL
350);
351CREATE TABLE IF NOT EXISTS push_subscriptions (
352 endpoint TEXT PRIMARY KEY,
353 token TEXT NOT NULL,
354 p256dh TEXT NOT NULL,
355 auth TEXT NOT NULL,
356 created_at INTEGER NOT NULL
357);
358CREATE TABLE IF NOT EXISTS apns_tokens (
359 apns_token TEXT PRIMARY KEY,
360 token TEXT NOT NULL,
361 sandbox INTEGER NOT NULL DEFAULT 0,
362 created_at INTEGER NOT NULL
363);
364-- Attachment/photo bytes. Two storage modes coexist so older DBs keep working:
365-- * disk-backed (new): bytes live at blobDir/id; the data column is empty, size>0.
366-- * legacy (pre-disk): bytes live in the data column; no file. getBlob falls back.
367-- thread_id ties a blob to its thread for GC + quota (NULL for legacy/branding).
368CREATE TABLE IF NOT EXISTS blobs (
369 id TEXT PRIMARY KEY,
370 data BLOB NOT NULL,
371 size INTEGER NOT NULL,
372 thread_id TEXT,
373 created_at INTEGER NOT NULL
374);
375CREATE TABLE IF NOT EXISTS users (
376 id TEXT PRIMARY KEY,
377 label TEXT NOT NULL DEFAULT '',
378 created_at INTEGER NOT NULL
379);
380CREATE TABLE IF NOT EXISTS key_invites (
381 id TEXT PRIMARY KEY,
382 user_id TEXT NOT NULL,
383 wrapped TEXT NOT NULL,
384 created_at INTEGER NOT NULL,
385 expires_at INTEGER,
386 used_at INTEGER
387);
388CREATE TABLE IF NOT EXISTS device_tokens (
389 token_hash TEXT PRIMARY KEY,
390 user_id TEXT NOT NULL,
391 label TEXT NOT NULL DEFAULT '',
392 created_at INTEGER NOT NULL
393);
394-- Link-preview cache, sealed at rest (docs/no-plaintext.md): the row key is a
395-- salted hash of the URL and the card is AES-GCM ciphertext under a key derived
396-- from the URL itself (convergent — only someone who knows the URL can find or
397-- open a row; the server derives it transiently at request time, when it sees
398-- the URL anyway). fetched_at stays plaintext for TTL eviction — a timestamp,
399-- like messages.created_at.
400CREATE TABLE IF NOT EXISTS link_previews_sealed (
401 url_hash TEXT PRIMARY KEY,
402 blob TEXT NOT NULL,
403 fetched_at INTEGER NOT NULL
404);
405CREATE TABLE IF NOT EXISTS threads (
406 id TEXT PRIMARY KEY,
407 kind TEXT NOT NULL, -- self | dm | group
408 name_blob TEXT, -- client-encrypted group name (null for dm/self)
409 created_at INTEGER NOT NULL
410);
411CREATE TABLE IF NOT EXISTS thread_members (
412 thread_id TEXT NOT NULL,
413 user_id TEXT NOT NULL,
414 created_at INTEGER NOT NULL,
415 PRIMARY KEY (thread_id, user_id)
416);
417CREATE TABLE IF NOT EXISTS thread_keys (
418 thread_id TEXT NOT NULL, -- the thread's AES key, wrapped to each
419 user_id TEXT NOT NULL, -- member's user public key (server-blind)
420 wrapped TEXT NOT NULL,
421 PRIMARY KEY (thread_id, user_id)
422);
423CREATE TABLE IF NOT EXISTS dm_pairs (
424 user_a TEXT NOT NULL, -- canonical (user_a < user_b) so there is
425 user_b TEXT NOT NULL, -- exactly one DM thread per pair
426 thread_id TEXT NOT NULL,
427 PRIMARY KEY (user_a, user_b)
428);
429-- one shared instance key, wrapped to each member's pubkey, for all-members
430-- content like profile photos. The server stays blind.
431CREATE TABLE IF NOT EXISTS instance_keys (
432 user_id TEXT PRIMARY KEY,
433 wrapped TEXT NOT NULL
434);
435-- Tiers (docs/community-and-tiers.md): admin-curated member sets that CARRY
436-- KEY MATERIAL — the opposite of users.role, which by invariant carries none.
437-- A member holds a set of tiers; each tier has its own AES key (tier_keys),
438-- and tier-gated open groups wrap their thread key under it. The name is
439-- plaintext BY DESIGN, like branding: admin-set space policy, never member
440-- content, and it must render where no key exists yet (invite claim screens,
441-- later checkout).
442CREATE TABLE IF NOT EXISTS tiers (
443 id TEXT PRIMARY KEY,
444 name TEXT NOT NULL,
445 created_at INTEGER NOT NULL
446);
447-- who's in a tier, and which assigner put them there ('admin' today;
448-- 'invite' when cohorts land, billing sources later — the assignment axis of
449-- the design doc; the machinery below this line never cares which).
450CREATE TABLE IF NOT EXISTS user_tiers (
451 tier_id TEXT NOT NULL,
452 user_id TEXT NOT NULL,
453 source TEXT NOT NULL DEFAULT 'admin',
454 created_at INTEGER NOT NULL,
455 PRIMARY KEY (tier_id, user_id)
456);
457-- each tier's key wrapped to each member's pubkey — instance_keys, per tier.
458-- Client-side wraps only; the server stores blobs it cannot open.
459CREATE TABLE IF NOT EXISTS tier_keys (
460 tier_id TEXT NOT NULL,
461 user_id TEXT NOT NULL,
462 wrapped TEXT NOT NULL,
463 PRIMARY KEY (tier_id, user_id)
464);
465-- refunded/disputed lifetime purchases (payment intent ids). A refund deletes
466-- the membership row — and with it the per-sub ordering watermark — so this
467-- tombstone is what stops a REDELIVERED grant (Stripe is at-least-once) from
468-- silently re-buying the level. Subscriptions don't need it: their redelivered
469-- receipts resolve against provider truth (SubState); a payment intent has no
470-- equivalent living state to ask about.
471CREATE TABLE IF NOT EXISTS revoked_payments (
472 payment_intent TEXT PRIMARY KEY,
473 revoked_at INTEGER NOT NULL
474);
475-- per-user "closed" chats: a thread hidden from that user's list (synced across
476-- their devices). Just a marker — no content. Cleared for everyone on new
477-- activity (see insertMessage) so a closed chat returns when it's messaged.
478CREATE TABLE IF NOT EXISTS hidden_threads (
479 user_id TEXT NOT NULL,
480 thread_id TEXT NOT NULL,
481 PRIMARY KEY (user_id, thread_id)
482);
483-- per-user pinned chats: threads that user keeps at the top of their list
484-- (synced across their devices). Just a marker — no content.
485CREATE TABLE IF NOT EXISTS pinned_threads (
486 user_id TEXT NOT NULL,
487 thread_id TEXT NOT NULL,
488 PRIMARY KEY (user_id, thread_id)
489);
490-- per-user muted chats: threads that user gets no notifications for (synced
491-- across their devices). The server consults it only to SKIP push sends — the
492-- same routing-preference class as hidden/pinned, never content. Other members
493-- never see it.
494CREATE TABLE IF NOT EXISTS muted_threads (
495 user_id TEXT NOT NULL,
496 thread_id TEXT NOT NULL,
497 PRIMARY KEY (user_id, thread_id)
498);
499-- a closed DM (issue #578): either party ended the conversation. The thread
500-- and its history stay (the words belong to both people), but the server
501-- refuses new messages/edits/typing into it until someone reopens it — a
502-- client-side-only close would be bypassable. Connection metadata only: who
503-- closed and when, never why.
504CREATE TABLE IF NOT EXISTS closed_dms (
505 thread_id TEXT PRIMARY KEY,
506 closed_by TEXT NOT NULL,
507 created_at INTEGER NOT NULL
508);
509-- blocks (issue #578): "blocker cut contact with blocked" — the server-side
510-- half of a block (the client side is a mute). Consulted at DM creation and
511-- reopen, group adds, and push fan-out; blocking also closes any existing DM.
512-- Connection metadata only (the dm_pairs class): the server learns that two
513-- members are no longer connectable, never why, and the blocked party gets
514-- the same generic refusal a closed DM door gives.
515CREATE TABLE IF NOT EXISTS blocks (
516 blocker_id TEXT NOT NULL,
517 blocked_id TEXT NOT NULL,
518 created_at INTEGER NOT NULL,
519 PRIMARY KEY (blocker_id, blocked_id)
520);
521-- bans (issue #578): removal that stays removed. Bans key on the member's
522-- PUBLIC KEY — the one durable handle in an E2EE roster — so the same key
523-- can't re-register through any invite (admin, member, or cohort). Honest
524-- limit, documented in docs/moderation.md: a determined person with a fresh
525-- invite and a fresh keypair is a new stranger; a ban stops re-entry, not
526-- reincarnation — pair it with pausing member invites during trouble.
527CREATE TABLE IF NOT EXISTS bans (
528 public_key TEXT PRIMARY KEY,
529 user_id TEXT NOT NULL,
530 banned_by TEXT NOT NULL,
531 banned_at INTEGER NOT NULL
532);
533-- reports (issue #578): the moderation queue's METADATA — who reported whom,
534-- where, and what became of it. The report's CONTENT (category, words, quoted
535-- evidence) never lands here: it travels in report_blobs, sealed to each
536-- admin's public key, unreadable by the server. Reports are testimony, not
537-- proof — the queue UI says so out loud — and the row here is the same
538-- connection-level class as thread membership.
539CREATE TABLE IF NOT EXISTS reports (
540 id TEXT PRIMARY KEY,
541 created_at INTEGER NOT NULL,
542 reporter_id TEXT NOT NULL,
543 reported_id TEXT NOT NULL,
544 thread_id TEXT NOT NULL DEFAULT '',
545 status TEXT NOT NULL DEFAULT 'open',
546 action TEXT NOT NULL DEFAULT '',
547 resolved_by TEXT NOT NULL DEFAULT '',
548 resolved_at INTEGER NOT NULL DEFAULT 0
549);
550CREATE TABLE IF NOT EXISTS report_blobs (
551 report_id TEXT NOT NULL,
552 admin_id TEXT NOT NULL,
553 blob TEXT NOT NULL,
554 PRIMARY KEY (report_id, admin_id)
555);
556-- audit_log (issue #578): every moderation action — who did what to whom,
557-- when, off which report. Append-only action metadata (the roster's class),
558-- served to admins only; any quoted content stays sealed in report_blobs,
559-- never here.
560CREATE TABLE IF NOT EXISTS audit_log (
561 id INTEGER PRIMARY KEY AUTOINCREMENT,
562 ts INTEGER NOT NULL,
563 actor_id TEXT NOT NULL,
564 action TEXT NOT NULL,
565 subject_id TEXT NOT NULL DEFAULT '',
566 thread_id TEXT NOT NULL DEFAULT '',
567 report_id TEXT NOT NULL DEFAULT '',
568 detail TEXT NOT NULL DEFAULT ''
569);
570-- per-user read watermark: the time of the newest message that user has read in
571-- a thread, so "read here" clears the unread dot on their other devices too.
572-- A timestamp only — no content, and it never affects other members.
573CREATE TABLE IF NOT EXISTS read_marks (
574 user_id TEXT NOT NULL,
575 thread_id TEXT NOT NULL,
576 last_read_at INTEGER NOT NULL,
577 PRIMARY KEY (user_id, thread_id)
578);
579-- Console-managed config for an agent (bot), keyed by its user id: the model,
580-- system prompt (personality), and reference docs the sidecar applies. This is
581-- operator config, not message content; the sidecar pulls it with its own token.
582CREATE TABLE IF NOT EXISTS bot_configs (
583 user_id TEXT PRIMARY KEY,
584 model TEXT NOT NULL DEFAULT '',
585 system_prompt TEXT NOT NULL DEFAULT '',
586 docs TEXT NOT NULL DEFAULT '',
587 updated_at INTEGER NOT NULL
588);
589-- Mini-app scheduling (docs: web/static/apps/README.md): a "future message" —
590-- client-encrypted ciphertext the server holds until deliver_at, then inserts
591-- into the thread's message log and fans out exactly like a posted message.
592-- The server learns only (thread, time); the payload stays opaque.
593CREATE TABLE IF NOT EXISTS scheduled_messages (
594 id TEXT PRIMARY KEY,
595 thread_id TEXT NOT NULL,
596 deliver_at INTEGER NOT NULL,
597 blob TEXT NOT NULL,
598 author_id TEXT NOT NULL, -- creator's user id (delivery attribution)
599 created_at INTEGER NOT NULL
600);
601CREATE INDEX IF NOT EXISTS idx_scheduled_deliver ON scheduled_messages(deliver_at);
602-- Mini-app state snapshots: one client-encrypted blob per (thread, slot), with a
603-- version counter for optimistic concurrency (two devices saving concurrently —
604-- the loser gets a conflict and refolds). The server can't read it; it's the same
605-- trust boundary as the thread's messages.
606CREATE TABLE IF NOT EXISTS thread_state (
607 thread_id TEXT NOT NULL,
608 slot TEXT NOT NULL,
609 blob TEXT NOT NULL,
610 version INTEGER NOT NULL,
611 updated_at INTEGER NOT NULL,
612 PRIMARY KEY (thread_id, slot)
613);
614-- One optional per-instance avatar: an admin-set branding photo shown where a
615-- person first meets the instance (onboarding, native add-chat, the home
616-- Save-passkey card). Like the group name, this is plaintext instance branding
617-- the server holds and serves in the clear — never user content, so it doesn't
618-- weaken E2E. Single row (id = 1); absent until an admin uploads one.
619CREATE TABLE IF NOT EXISTS instance_avatar (
620 id INTEGER PRIMARY KEY CHECK (id = 1),
621 data BLOB NOT NULL,
622 mime TEXT NOT NULL,
623 updated_at INTEGER NOT NULL
624);
625-- Connected third-party services (docs/connected-services.md): one opaque,
626-- client-encrypted credential blob per (member, service) — e.g. a scoped
627-- DNSimple token the member sealed to their OWN key. The server keys it by
628-- member + service and never reads it (same trust boundary as instance_keys).
629-- Actually *using* a credential is a separate, time-boxed grant the member seals
630-- to a bot; nothing usable rests here.
631CREATE TABLE IF NOT EXISTS connected_services (
632 user_id TEXT NOT NULL,
633 service TEXT NOT NULL,
634 blob TEXT NOT NULL,
635 updated_at INTEGER NOT NULL,
636 PRIMARY KEY (user_id, service)
637);
638-- Custom mini-apps installed via the admin console (customapps.go): plain JS
639-- module source served to every member at /static/apps-custom/<name>.js. This
640-- is admin-chosen CODE, not user content — the compiled-in apps ship in the
641-- clear the same way — so, like branding, it lives unencrypted. version bumps
642-- on every update so the admin list can tell revisions apart.
643CREATE TABLE IF NOT EXISTS custom_apps (
644 name TEXT PRIMARY KEY,
645 source TEXT NOT NULL,
646 enabled INTEGER NOT NULL DEFAULT 1,
647 version INTEGER NOT NULL DEFAULT 1,
648 created_at INTEGER NOT NULL,
649 updated_at INTEGER NOT NULL
650);
651-- Member-built mini-apps (memberapps.go): unlike custom_apps, this IS user
652-- content, so the blob is client-encrypted under the instance key —
653-- { source, manifest } sealed before it reaches us. The id is a random slug,
654-- so the server never learns the app's name, code, or author-chosen anything.
655-- author_id records which member deployed it; owner_id records the member it
656-- belongs to (the same person for a hand-deployed app; the requester for one a
657-- bot built on their behalf). Either may update/delete it — that's the gate.
658CREATE TABLE IF NOT EXISTS member_apps (
659 id TEXT PRIMARY KEY,
660 blob TEXT NOT NULL,
661 author_id TEXT NOT NULL,
662 owner_id TEXT,
663 version INTEGER NOT NULL DEFAULT 1,
664 created_at INTEGER NOT NULL,
665 updated_at INTEGER NOT NULL
666);
667-- Version history: every deploy of an app records its blob here, keyed by
668-- (app_id, version). member_apps holds the CURRENT blob + version; this is the
669-- trail, so a member can restore an earlier one (which deploys as a new version).
670-- Opaque like the current blob — the server never opens it. Pruned to the last N.
671CREATE TABLE IF NOT EXISTS member_app_versions (
672 app_id TEXT NOT NULL,
673 version INTEGER NOT NULL,
674 blob TEXT NOT NULL,
675 author_id TEXT NOT NULL,
676 created_at INTEGER NOT NULL,
677 based_on INTEGER, -- the version this one was reverted from ("based on vN"), or NULL
678 PRIMARY KEY (app_id, version)
679);
680-- Member bots (docs/member-bots.md): a member's autonomous bot as an opaque
681-- charter blob — persona + schedule + task, sealed under the instance key
682-- before it reaches us, exactly like a member app's { source, manifest }.
683-- The sidecar (an ordinary E2EE member) decrypts charters and runs them; the
684-- server learns only (random id, author, owner, size, times).
685CREATE TABLE IF NOT EXISTS member_bots (
686 id TEXT PRIMARY KEY,
687 blob TEXT NOT NULL,
688 author_id TEXT NOT NULL,
689 owner_id TEXT,
690 created_at INTEGER NOT NULL,
691 updated_at INTEGER NOT NULL
692);
693-- Incoming webhooks (docs/webhooks.md, #610): each row is one webhook — an
694-- agent identity that turns inbound HTTP into encrypted typed events. Both
695-- blobs are opaque here: def_blob is sealed to the hooks sidecar's service
696-- identity (it alone can read the webhook's private key, member token, and
697-- URL secret); meta_blob is instance-key-encrypted (the admin app's list
698-- view). The server learns only (id, which agent user, sizes, times) — never
699-- a secret, a key, or which external service feeds it. deployed_at is the
700-- sidecar's plaintext "route is live" ack, 0 until the first poll picks it up.
701CREATE TABLE IF NOT EXISTS webhooks (
702 id TEXT PRIMARY KEY,
703 user_id TEXT NOT NULL,
704 def_blob TEXT NOT NULL DEFAULT '',
705 meta_blob TEXT NOT NULL DEFAULT '',
706 deployed_at INTEGER NOT NULL DEFAULT 0,
707 created_at INTEGER NOT NULL
708);
709-- Tamper evidence (docs/tamper-evidence.md, #573): the append-only event
710-- journal. The messages table mutates on purpose (edits UPDATE, unsend hard-
711-- DELETEs), so checkpoints commit to this journal instead: every message
712-- insert/edit/delete appends one row IN THE SAME TRANSACTION. blob_hash is
713-- sha256 hex of the ciphertext — never the ciphertext itself, so unsend still
714-- destroys the blob; the journal retains only that an event happened. Rows are
715-- never updated or deleted. Adding a plaintext column here would break the one
716-- rule — don't.
717CREATE TABLE IF NOT EXISTS event_journal (
718 seq INTEGER PRIMARY KEY AUTOINCREMENT,
719 ts INTEGER NOT NULL,
720 kind TEXT NOT NULL, -- 'msg' | 'edit' | 'delete'
721 msg_id INTEGER NOT NULL,
722 thread_id TEXT NOT NULL,
723 blob_hash TEXT NOT NULL -- '' for delete
724);
725-- Signed checkpoint chain over the journal (checkpoint.go). "signed" is the
726-- full signed wrapper clients verify; the bare columns exist so the server
727-- can chain and page without re-parsing it.
728CREATE TABLE IF NOT EXISTS checkpoints (
729 id INTEGER PRIMARY KEY AUTOINCREMENT,
730 seq_from INTEGER NOT NULL,
731 seq_to INTEGER NOT NULL,
732 root TEXT NOT NULL,
733 chain TEXT NOT NULL,
734 ts INTEGER NOT NULL,
735 signed TEXT NOT NULL
736);
737-- Two checkpoints claiming the same journal range would fork the chain — a
738-- second writer (e.g. two processes briefly overlapping on one DB during an
739-- activator wake race) must fail loudly, not fork silently.
740CREATE UNIQUE INDEX IF NOT EXISTS idx_checkpoints_seq_from ON checkpoints(seq_from);
741-- There is at most ONE space thread per instance (spacethread.go): the
742-- create-if-absent transaction relies on this to make a racing second create
743-- fail loudly instead of forking the space log into two threads.
744CREATE UNIQUE INDEX IF NOT EXISTS idx_one_space_thread ON threads(kind) WHERE kind = 'space';`)
745 if err != nil {
746 return err
747 }
748 // Additive column migrations (older DBs created `messages` without these).
749 // author_hash is hashToken(senderToken) — lets the server enforce that only
750 // the original author may edit, without ever seeing the (encrypted) author
751 // name. edited_at is set when a message is edited in place.
752 for _, c := range []string{
753 "ALTER TABLE messages ADD COLUMN author_hash TEXT",
754 "ALTER TABLE messages ADD COLUMN edited_at INTEGER",
755 // Multi-thread (docs/threads-and-dms.md): which thread a message belongs to,
756 // and each user's ECDH public key (the roster). Both additive over old DBs.
757 "ALTER TABLE messages ADD COLUMN thread_id TEXT",
758 "ALTER TABLE users ADD COLUMN public_key TEXT",
759 // Key transparency (#573, docs/tamper-evidence.md): the joiner's HMAC of
760 // its public key under bind_key = HKDF(invite secret T). A T-holder (the
761 // inviter) verifies it before trusting/wrapping to the member, catching a
762 // server that substituted its own key. Opaque to the server (no T).
763 "ALTER TABLE users ADD COLUMN bind_mac TEXT",
764 // Profile photo: an encrypted blob id (encrypted with the instance key).
765 "ALTER TABLE users ADD COLUMN avatar_blob TEXT",
766 // A person's display name encrypted under the instance key (server-blind).
767 // Bots keep their plaintext `label` (admin-authored); people move here so the
768 // server never holds a readable name. Set by the member who holds the key.
769 "ALTER TABLE users ADD COLUMN label_blob TEXT",
770 // The profile photo's alt text, encrypted under the instance key like the
771 // label — unlike the server photo's plaintext alt (branding), a member's
772 // photo description is user content and never readable by the server.
773 "ALTER TABLE users ADD COLUMN avatar_alt_blob TEXT",
774 // Group photo: an encrypted blob id (encrypted with the thread key).
775 "ALTER TABLE threads ADD COLUMN avatar_blob TEXT",
776 // User invites: the pending DM thread an invite bootstraps the joiner into
777 // (null for admin auth-only / add-device invites). See createUserInvite.
778 "ALTER TABLE key_invites ADD COLUMN thread_id TEXT",
779 // Proof-of-secret for claiming: hashToken of a value derived from the invite
780 // secret T (which never reaches the server). Claiming requires presenting the
781 // preimage, so reading the DB (which stores only the id + this hash) is not
782 // enough to claim a pending invite. See deriveInvite in crypto.js.
783 "ALTER TABLE key_invites ADD COLUMN claim_hash TEXT",
784 // Legacy invites are removed (superseded by key-invites). Their tokens were
785 // stored in clear and are no longer accepted; drop the table so a stolen DB
786 // retains no plaintext credentials. Members re-authenticate via device tokens.
787 "DROP TABLE IF EXISTS invites",
788 // The reactions table is vestigial: reactions became encrypted message
789 // events (the chat.react fold), so nothing writes or reads it anymore.
790 // Legacy rows stored the reactor's RAW session token in the clear — drop
791 // the table so a stolen DB retains no plaintext credentials (same
792 // rationale as the invites drop above).
793 "DROP TABLE IF EXISTS reactions",
794 // The old link-preview cache stored plaintext URLs + OpenGraph cards
795 // derived from message content, unevicted. Superseded by
796 // link_previews_sealed (ciphertext at rest); it's a cache, so old rows
797 // are simply dropped and re-fetched on demand.
798 "DROP TABLE IF EXISTS link_previews",
799 // Which user a push subscription belongs to, so removing a user can drop their
800 // subscriptions (sign-out clears them by token; removal has only the user id).
801 "ALTER TABLE push_subscriptions ADD COLUMN user_id TEXT",
802 // Store only a HASH of the session token in the push/APNs tables — the raw
803 // token is a live credential (cf. device_tokens.token_hash). Backfilled and
804 // scrubbed below for rows created before this migration.
805 "ALTER TABLE push_subscriptions ADD COLUMN token_hash TEXT",
806 "ALTER TABLE apns_tokens ADD COLUMN token_hash TEXT",
807 // Bot flag: a user driven by an agent (see agent/), not a person. Only
808 // used to badge them in the UI — it grants no privilege.
809 "ALTER TABLE users ADD COLUMN is_agent INTEGER NOT NULL DEFAULT 0",
810 // Helpers catalog (docs/bots.md): admin-authored one-line description shown
811 // to members, and the "show in members' Helpers list" flag. Catalog metadata
812 // like the roster label — in clear, never message content.
813 "ALTER TABLE bot_configs ADD COLUMN description TEXT NOT NULL DEFAULT ''",
814 "ALTER TABLE bot_configs ADD COLUMN available_to_members INTEGER NOT NULL DEFAULT 0",
815 // Listen mode (docs/bots.md): when a bot replies in a group — ''/'mention'
816 // = only on an @mention (the default), 'name' = also when spoken to by
817 // bare name (the sidecar still gates that through a classifier).
818 "ALTER TABLE bot_configs ADD COLUMN listen TEXT NOT NULL DEFAULT ''",
819 // Helper kind (docs/bots.md): 'ai' (LLM-backed — messages it sees are sent
820 // to a model provider) vs 'bot' (deterministic — nothing leaves the server).
821 // The sidecar SELF-DECLARES this on connect, so Eleven reads BOT while it
822 // runs commands-only and AI once its model brain is live. Badge only; no
823 // privilege. Default 'ai' so a pre-declare agent row reads as an assistant.
824 "ALTER TABLE bot_configs ADD COLUMN kind TEXT NOT NULL DEFAULT 'ai'",
825 // Admin-tweakable welcome DM text used by the guide (Eleven's welcomer).
826 // '' = the shipped default; a literal '{name}' is replaced with the new
827 // member's name. Operator copy, like the persona — never message content.
828 "ALTER TABLE bot_configs ADD COLUMN welcome TEXT NOT NULL DEFAULT ''",
829 // Blobs gain a thread tag (for thread-scoped GC + quota accounting). Older
830 // blobs (branding, pre-disk photos) stay NULL and are never thread-GC'd.
831 "ALTER TABLE blobs ADD COLUMN thread_id TEXT",
832 // Federated blobs Phase A (#992): the ciphertext's sha256 (hex),
833 // recorded at upload — the integrity anchor mirror and audit sweeps
834 // verify against. '' = pre-hash row; the mirror sweep backfills it
835 // (it reads the full bytes anyway).
836 "ALTER TABLE blobs ADD COLUMN sha256 TEXT NOT NULL DEFAULT ''",
837 // When the mirror store last received this blob (unix seconds; 0 =
838 // unmirrored — what the mirror sweep selects on). Reset to 0 fleet-wide
839 // when the mirror TARGET changes (documented beside the env vars);
840 // Phase B's store registry replaces this with per-store placement.
841 "ALTER TABLE blobs ADD COLUMN mirrored_at INTEGER NOT NULL DEFAULT 0",
842 // Roles (docs/conversational-onboarding.md): 'admin' | 'moderator' |
843 // 'member'. Authorisation only — a role grants no key material. The admin
844 // bearer token remains as break-glass; role-admins reach the same admin API
845 // with their own device token. Moderator is reserved (nothing enforces it
846 // yet). Existing members default to 'member'.
847 "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member'",
848 // A setup invite (minted by add-instance for a fresh instance) makes its
849 // claimer the first admin. Ordinary invites never carry this.
850 "ALTER TABLE key_invites ADD COLUMN grants_admin INTEGER NOT NULL DEFAULT 0",
851 // Member app ownership: who the app belongs to (may differ from the
852 // deployer — a bot deploys under its own author_id but sets owner_id to the
853 // requester). Nullable: apps deployed before this stay owner-less and remain
854 // managed by their author, as before.
855 "ALTER TABLE member_apps ADD COLUMN owner_id TEXT",
856 // The version a reverted deploy was based on ("based on vN"), or NULL.
857 "ALTER TABLE member_app_versions ADD COLUMN based_on INTEGER",
858 // Pinned presence: '' (automatic) or 'away' (the member set themselves
859 // Away, so activity doesn't flip them back online). Connection metadata
860 // like role — the server already holds the sockets — never content.
861 "ALTER TABLE users ADD COLUMN presence TEXT NOT NULL DEFAULT ''",
862 // A person's free-text "about me", encrypted under the instance key like
863 // their name and photo alt — user content, never readable by the server.
864 "ALTER TABLE users ADD COLUMN description_blob TEXT",
865 // A member's private per-viewer name overrides ("I call them Dermo",
866 // issue #577), sealed to their OWN public key — unlike the instance-key
867 // blobs above, not even other members can open it; the server holds
868 // ciphertext either way.
869 "ALTER TABLE users ADD COLUMN nicknames_blob TEXT",
870 // Compare-and-swap counter for the blob above (same optimistic
871 // versioning as the mini-app state slots): a save from a stale read
872 // 409s instead of clobbering another device's write.
873 "ALTER TABLE users ADD COLUMN nicknames_version INTEGER NOT NULL DEFAULT 0",
874 // Open "community" groups (docs/community-and-tiers.md): a group any
875 // member can discover and self-join. visibility is 'invite' (default)
876 // or 'open'; open_key is the thread's AES key encrypted under the
877 // shared instance key — every member can unwrap it client-side, while
878 // the server (which never holds the instance key) cannot.
879 "ALTER TABLE threads ADD COLUMN visibility TEXT NOT NULL DEFAULT 'invite'",
880 "ALTER TABLE threads ADD COLUMN open_key TEXT",
881 // Default groups (Slack's #general): open groups every NEW member is
882 // auto-joined to on their first device claim (existing members are
883 // never force-joined). Admin-set, meaningful only while visibility is
884 // 'open' — joining still needs no key material beyond open_key.
885 "ALTER TABLE threads ADD COLUMN default_join INTEGER NOT NULL DEFAULT 0",
886 // Cohort invites (docs/community-and-tiers.md): an invite may carry an
887 // access level — its claimer is assigned the level on first-device
888 // claim ('invite' source). Admin-set only.
889 "ALTER TABLE key_invites ADD COLUMN tier_id TEXT",
890 // Level visibility policy (admin-set, per level): 'shown' (on everyone's
891 // profile — the default, = today's behaviour), 'hidden' (only the
892 // level's own members see who's in it — non-members never do), or
893 // 'user' (each member chooses via user_tiers.visible). NB: "hidden"
894 // can only mean hidden-from-NON-members — a level's members share its
895 // key, so they inherently know each other (E2E, not a choice).
896 "ALTER TABLE tiers ADD COLUMN visibility TEXT NOT NULL DEFAULT 'shown'",
897 // Per-member show/hide, consulted only when the level's policy is
898 // 'user'. Default 1 = shown.
899 "ALTER TABLE user_tiers ADD COLUMN visible INTEGER NOT NULL DEFAULT 1",
900 // One-way mint marker: set (transactionally, with the first grants)
901 // when a level's key is first created. "Zero key rows" must NOT mean
902 // "never minted" — holders can all be removed later, and silently
903 // minting a SECOND key would seal every group gated under the first.
904 "ALTER TABLE tiers ADD COLUMN key_minted INTEGER NOT NULL DEFAULT 0",
905 // Level-gated open groups: the tier whose members may discover/join
906 // this open group ('' / NULL = every member; the open_key is then
907 // wrapped under that TIER's key instead of the instance key). Fixed
908 // once open_key exists — changing the gate would need a rekey the
909 // never-overwrite rule forbids (close the group and make a new one).
910 "ALTER TABLE threads ADD COLUMN open_tier TEXT",
911 // Paid access levels (docs/community-and-tiers.md layer 3): an access
912 // level can be sellable — members subscribe to it. Price is plaintext
913 // commerce config (admin-set, like branding), in minor units (cents).
914 // provider_ref is the payment provider's price id (Stripe price). All
915 // unset → the level is admin/invite-assigned only (no subscribe path).
916 "ALTER TABLE tiers ADD COLUMN price_cents INTEGER NOT NULL DEFAULT 0",
917 "ALTER TABLE tiers ADD COLUMN currency TEXT NOT NULL DEFAULT ''",
918 "ALTER TABLE tiers ADD COLUMN interval TEXT NOT NULL DEFAULT ''", // 'month' | 'year'
919 "ALTER TABLE tiers ADD COLUMN provider_ref TEXT",
920 // Subscription entitlement on a membership (source='stripe'): its
921 // lifecycle. status '' for admin/invite assignments; 'active' |
922 // 'past_due' | 'canceled' for paid ones. paid_until is the paid-through
923 // time (the current period end). grace_deadline is when access is
924 // finally revoked after lapse/cancel (paid_until + the grace window);
925 // 0 = not lapsing. ext_sub/ext_customer are the provider's ids, so a
926 // webhook can find the membership again.
927 "ALTER TABLE user_tiers ADD COLUMN status TEXT NOT NULL DEFAULT ''",
928 "ALTER TABLE user_tiers ADD COLUMN paid_until INTEGER NOT NULL DEFAULT 0",
929 "ALTER TABLE user_tiers ADD COLUMN grace_deadline INTEGER NOT NULL DEFAULT 0",
930 "ALTER TABLE user_tiers ADD COLUMN ext_sub TEXT",
931 "ALTER TABLE user_tiers ADD COLUMN ext_customer TEXT",
932 // The provider event `created` time last folded for this subscription —
933 // so a redelivered or out-of-order webhook (Stripe is at-least-once and
934 // can reorder) is ignored rather than resurrecting a revoked membership.
935 "ALTER TABLE user_tiers ADD COLUMN ext_event_ts INTEGER NOT NULL DEFAULT 0",
936 // Lifetime (one-time) purchases: a paid membership with no lifecycle —
937 // pay once, keep access. The flag is what exempts the row from every
938 // renewal-shaped sweep (grace, silent-lapse backstop, repair); the only
939 // way out is a refund/chargeback event or an admin removal.
940 "ALTER TABLE user_tiers ADD COLUMN lifetime INTEGER NOT NULL DEFAULT 0",
941 // Delete tombstones (#581): unsending hard-deletes the row (the
942 // ciphertext really goes) and inserts a fresh empty row whose
943 // tombstone_of names the deleted id. The fresh autoincrement id lands
944 // above every client's forward-sync watermark, so offline clients
945 // replay the delete from ordinary catch-up — the live broadcast stays
946 // the instant path. Stores only (thread, target id, deleted-at):
947 // metadata the server transiently knew when it performed the delete.
948 "ALTER TABLE messages ADD COLUMN tombstone_of INTEGER",
949 // A member's private moderation preferences (muted people, for now),
950 // sealed to their OWN public key like nicknames_blob — the server can't
951 // read who muted whom, it just syncs the ciphertext across their devices.
952 "ALTER TABLE users ADD COLUMN prefs_blob TEXT",
953 "ALTER TABLE users ADD COLUMN prefs_version INTEGER NOT NULL DEFAULT 0",
954 // DM privacy (issue #578): who may open a NEW direct message with this
955 // member. '' = everyone in the space (the default, today's behaviour);
956 // 'nobody' = no one — with a disclosed carve-out for admins and bots
957 // (the operator must stay reachable; onboarding speaks through Eleven's
958 // welcome DM). Enforced server-side at DM creation, where a client-only
959 // check would be bypassable. A routing preference, never content.
960 "ALTER TABLE users ADD COLUMN dm_privacy TEXT NOT NULL DEFAULT ''",
961 // Invite lineage (issue #578): which member's invite brought this
962 // person in ('' for admin/setup invites and everyone pre-dating the
963 // column). Written at claim time; the report queue shows it so a
964 // problem member's way in is visible.
965 "ALTER TABLE users ADD COLUMN invited_by TEXT NOT NULL DEFAULT ''",
966 } {
967 if _, err := db.sql.Exec(c); err != nil && !strings.Contains(err.Error(), "duplicate column") {
968 return err
969 }
970 }
971 // Backfill each app's CURRENT version into its history, for apps deployed
972 // before version tracking existed, so History always shows at least the
973 // current version (never empty for an app that hasn't been updated since).
974 if err := db.backfillMemberAppVersions(); err != nil {
975 return err
976 }
977 // Baseline pre-journal messages into the event journal (tamper evidence) —
978 // without this, editing or unsending a message that predates the journal
979 // would append an 'edit'/'delete' row for a message the journal never saw,
980 // which reads as tampering to a verifier.
981 if err := db.backfillEventJournal(); err != nil {
982 return err
983 }
984 // Established instances never ran the setup conversation — mark it complete
985 // ONCE at upgrade time, so promoting someone to admin later doesn't drop them
986 // into first-run onboarding on a live space. Guarded by its own marker: a
987 // fresh instance (no devices yet) records the marker but leaves setup pending
988 // for its first claimer, and a restart mid-setup doesn't re-run the check.
989 if v, _, _ := db.getConfig("setup_backfilled"); v != "1" {
990 var devices int
991 db.sql.QueryRow("SELECT COUNT(*) FROM device_tokens").Scan(&devices)
992 if devices > 0 {
993 if err := db.setSetupComplete(true); err != nil {
994 return err
995 }
996 }
997 if err := db.setConfig("setup_backfilled", "1"); err != nil {
998 return err
999 }
1000 }
1001 // Older push/APNs rows stored the session token in clear; replace it with its
1002 // hash and scrub the plaintext so a stolen DB yields no live credentials.
1003 return db.backfillPushTokenHashes()
1004}
1005
1006// backfillPushTokenHashes migrates any legacy plaintext push/APNs token to a hash
1007// and clears the plaintext column, so the tables never retain a usable credential.
1008func (db *DB) backfillPushTokenHashes() error {
1009 if err := db.backfillTokenHash("push_subscriptions", "endpoint"); err != nil {
1010 return err
1011 }
1012 return db.backfillTokenHash("apns_tokens", "apns_token")
1013}
1014
1015// backfillTokenHash rewrites rows that still hold a plaintext token. table/keyCol
1016// are in-code constants, never user input, so the string-built SQL is safe.
1017func (db *DB) backfillTokenHash(table, keyCol string) error {
1018 rows, err := db.sql.Query(
1019 "SELECT " + keyCol + ", token FROM " + table +
1020 " WHERE (token_hash IS NULL OR token_hash = '') AND token != ''")
1021 if err != nil {
1022 return err
1023 }
1024 type legacy struct{ key, token string }
1025 var pending []legacy
1026 for rows.Next() {
1027 var r legacy
1028 if err := rows.Scan(&r.key, &r.token); err != nil {
1029 rows.Close()
1030 return err
1031 }
1032 pending = append(pending, r)
1033 }
1034 rows.Close()
1035 if err := rows.Err(); err != nil {
1036 return err
1037 }
1038 // Update after the read completes (single-connection SQLite dislikes writing
1039 // mid-iteration).
1040 for _, r := range pending {
1041 if _, err := db.sql.Exec(
1042 "UPDATE "+table+" SET token_hash = ?, token = '' WHERE "+keyCol+" = ?",
1043 hashToken(r.token), r.key,
1044 ); err != nil {
1045 return err
1046 }
1047 }
1048 return nil
1049}
1050
1051// ---- link previews (cache, sealed at rest) -------------------------------
1052//
1053// Rows are found by a salted hash of the URL and opened with a key derived
1054// from the URL (see sealPreview/openPreview in unfurl.go). The per-instance
1055// random salt only defeats precomputation across instances — it is not a
1056// secret; the protection at rest is the URL's own entropy, which is exactly
1057// the honest claim: bulk reading of the cache is gone, guessable URLs are
1058// guessable (docs/no-plaintext.md § the plan).
1059
1060// previewSalt returns the instance's preview-cache salt, minting it on first
1061// use (INSERT OR IGNORE + read-back keeps concurrent first calls agreeing).
1062func (db *DB) previewSalt() (string, error) {
1063 salt, err := randToken()
1064 if err != nil {
1065 return "", err
1066 }
1067 if _, err := db.sql.Exec(
1068 "INSERT OR IGNORE INTO config(key, value) VALUES('preview_salt', ?)", salt); err != nil {
1069 return "", err
1070 }
1071 v, _, err := db.getConfig("preview_salt")
1072 return v, err
1073}
1074
1075func (db *DB) getLinkPreview(u, lang string) (LinkPreview, bool, error) {
1076 salt, err := db.previewSalt()
1077 if err != nil {
1078 return LinkPreview{}, false, err
1079 }
1080 key := previewCacheKey(lang, u)
1081 var blob string
1082 var fetchedAt int64
1083 err = db.sql.QueryRow(
1084 "SELECT blob, fetched_at FROM link_previews_sealed WHERE url_hash = ?", previewURLHash(salt, key),
1085 ).Scan(&blob, &fetchedAt)
1086 if err == sql.ErrNoRows {
1087 return LinkPreview{}, false, nil
1088 }
1089 if err != nil {
1090 return LinkPreview{}, false, err
1091 }
1092 pv, ok := openPreview(salt, key, blob)
1093 if !ok {
1094 // Undecryptable row (corruption): treat as a miss; the next put
1095 // overwrites it under the same hash.
1096 return LinkPreview{}, false, nil
1097 }
1098 pv.URL = u
1099 pv.FetchedAt = fetchedAt
1100 return pv, true, nil
1101}
1102
1103func (db *DB) putLinkPreview(pv LinkPreview, lang string) error {
1104 salt, err := db.previewSalt()
1105 if err != nil {
1106 return err
1107 }
1108 key := previewCacheKey(lang, pv.URL)
1109 blob, err := sealPreview(salt, key, pv)
1110 if err != nil {
1111 return err
1112 }
1113 // Put-time TTL sweep: the cache self-prunes with activity, so previews of
1114 // long-forgotten URLs don't sit at rest (or in hibernation replicas) forever.
1115 db.sql.Exec("DELETE FROM link_previews_sealed WHERE fetched_at < ?",
1116 time.Now().Unix()-previewMaxAge)
1117 _, err = db.sql.Exec(
1118 `INSERT INTO link_previews_sealed(url_hash, blob, fetched_at)
1119 VALUES(?, ?, ?)
1120 ON CONFLICT(url_hash) DO UPDATE SET
1121 blob=excluded.blob, fetched_at=excluded.fetched_at`,
1122 previewURLHash(salt, key), blob, pv.FetchedAt,
1123 )
1124 return err
1125}
1126
1127// ---- config -------------------------------------------------------------
1128
1129func (db *DB) getConfig(key string) (string, bool, error) {
1130 var v string
1131 err := db.sql.QueryRow("SELECT value FROM config WHERE key = ?", key).Scan(&v)
1132 if err == sql.ErrNoRows {
1133 return "", false, nil
1134 }
1135 if err != nil {
1136 return "", false, err
1137 }
1138 return v, true, nil
1139}
1140
1141func (db *DB) setConfig(key, value string) error {
1142 _, err := db.sql.Exec(
1143 "INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
1144 key, value,
1145 )
1146 return err
1147}
1148
1149// setConfigTx sets a config value within a transaction.
1150func setConfigTx(tx *sql.Tx, key, value string) error {
1151 _, err := tx.Exec(
1152 "INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
1153 key, value,
1154 )
1155 return err
1156}
1157
1158// ---- admin token --------------------------------------------------------
1159//
1160// The admin token only authorises invite management. It is NOT an encryption
1161// key — even with it, an attacker cannot read messages. We store only its hash.
1162
1163func hashToken(t string) string {
1164 h := sha256.Sum256([]byte(t))
1165 return hex.EncodeToString(h[:])
1166}
1167
1168// validClaimSecret reports whether claimSecret is the preimage of the stored
1169// invite claim_hash, compared in constant time. An invite with no claim_hash
1170// (created before this migration) is unclaimable — we deliberately do not fall
1171// back to unauthenticated claiming.
1172func validClaimSecret(claimHash, claimSecret string) bool {
1173 if claimHash == "" || claimSecret == "" {
1174 return false
1175 }
1176 return subtle.ConstantTimeCompare([]byte(claimHash), []byte(hashToken(claimSecret))) == 1
1177}
1178
1179func randToken() (string, error) {
1180 b := make([]byte, 32)
1181 if _, err := rand.Read(b); err != nil {
1182 return "", err
1183 }
1184 return base64.RawURLEncoding.EncodeToString(b), nil
1185}
1186
1187// ensureAdminToken sets up the admin token. If env is non-empty it is used.
1188// Otherwise, if none exists yet, a fresh one is generated. Returns the
1189// plaintext token only when a new one was generated (so it can be printed once).
1190func (db *DB) ensureAdminToken(env string) (string, error) {
1191 if env != "" {
1192 return "", db.setConfig("admin_token_hash", hashToken(env))
1193 }
1194 if _, ok, err := db.getConfig("admin_token_hash"); err != nil || ok {
1195 return "", err
1196 }
1197 tok, err := randToken()
1198 if err != nil {
1199 return "", err
1200 }
1201 return tok, db.setConfig("admin_token_hash", hashToken(tok))
1202}
1203
1204func (db *DB) resetAdminToken() (string, error) {
1205 tok, err := randToken()
1206 if err != nil {
1207 return "", err
1208 }
1209 return tok, db.setConfig("admin_token_hash", hashToken(tok))
1210}
1211
1212func (db *DB) checkAdmin(token string) bool {
1213 want, ok, err := db.getConfig("admin_token_hash")
1214 if err != nil || !ok {
1215 return false
1216 }
1217 return subtle.ConstantTimeCompare([]byte(hashToken(token)), []byte(want)) == 1
1218}
1219
1220// ---- users, key-invites, device tokens (invite redesign) ----------------
1221//
1222// The server stores a client-WRAPPED group key per invite, keyed by an id the
1223// client derives from a secret it never sends (see docs/invite-redesign.md). The
1224// server can't unwrap. Claiming an invite mints a device token for ongoing auth.
1225
1226type User struct {
1227 ID string `json:"id"`
1228 Label string `json:"label"`
1229 CreatedAt int64 `json:"created_at"`
1230 // Devices is how many devices have claimed in (0 = invited but not joined).
1231 Devices int `json:"devices"`
1232 // JoinedAt is when they first claimed a device (nil until they join).
1233 JoinedAt *int64 `json:"joined_at,omitempty"`
1234 // Pending is true while an unused, unexpired invite link is still out there.
1235 Pending bool `json:"pending"`
1236 // PendingDM is true when that pending invite drops them into a DM (a member's
1237 // in-app invite), vs a bare admin invite. The admin doesn't re-send DM invites —
1238 // the inviting member does, in-app.
1239 PendingDM bool `json:"pending_dm"`
1240 // IsAgent marks a bot, so the console can badge it and offer "Edit bot".
1241 IsAgent bool `json:"is_agent,omitempty"`
1242 // Kind is "ai" or "bot" for agents (drives the admin AI/BOT badge); "" for people.
1243 Kind string `json:"kind,omitempty"`
1244 // Role is 'admin' | 'moderator' | 'member' (authorisation only, no keys).
1245 Role string `json:"role"`
1246}
1247
1248func (db *DB) createUser(label string) (string, error) {
1249 id, err := randToken()
1250 if err != nil {
1251 return "", err
1252 }
1253 _, err = db.sql.Exec("INSERT INTO users(id, label, created_at) VALUES(?, ?, ?)", id, label, time.Now().Unix())
1254 return id, err
1255}
1256
1257// createDeviceToken mints a device token for an existing user outside the
1258// invite-claim path — used for webhook identities (hooks.go), whose creator
1259// receives the token once and seals it into the sidecar's definition blob.
1260// Same storage discipline as claimKeyInvite: only the hash is kept.
1261func (db *DB) createDeviceToken(userID, label string) (string, error) {
1262 tok, err := randToken()
1263 if err != nil {
1264 return "", err
1265 }
1266 _, err = db.sql.Exec(
1267 "INSERT INTO device_tokens(token_hash, user_id, label, created_at) VALUES(?, ?, ?, ?)",
1268 hashToken(tok), userID, label, time.Now().Unix())
1269 if err != nil {
1270 return "", err
1271 }
1272 return tok, nil
1273}
1274
1275func (db *DB) listUsers() ([]User, error) {
1276 now := time.Now().Unix()
1277 rows, err := db.sql.Query(`
1278 SELECT u.id, u.label, u.created_at, COALESCE(u.is_agent, 0), COALESCE(u.role, 'member'),
1279 (SELECT COUNT(*) FROM device_tokens d WHERE d.user_id = u.id),
1280 (SELECT MIN(d.created_at) FROM device_tokens d WHERE d.user_id = u.id),
1281 (SELECT COUNT(*) FROM key_invites k WHERE k.user_id = u.id
1282 AND k.used_at IS NULL AND (k.expires_at IS NULL OR k.expires_at > ?)),
1283 (SELECT COUNT(*) FROM key_invites k WHERE k.user_id = u.id
1284 AND k.thread_id IS NOT NULL AND k.used_at IS NULL),
1285 COALESCE((SELECT kind FROM bot_configs b WHERE b.user_id = u.id), 'ai')
1286 FROM users u ORDER BY u.created_at DESC`, now)
1287 if err != nil {
1288 return nil, err
1289 }
1290 defer rows.Close()
1291 out := []User{}
1292 for rows.Next() {
1293 var u User
1294 var joinedAt sql.NullInt64
1295 var pending, pendingDM, isAgent int
1296 var kind string
1297 if err := rows.Scan(&u.ID, &u.Label, &u.CreatedAt, &isAgent, &u.Role, &u.Devices, &joinedAt, &pending, &pendingDM, &kind); err != nil {
1298 return nil, err
1299 }
1300 if joinedAt.Valid {
1301 j := joinedAt.Int64
1302 u.JoinedAt = &j
1303 }
1304 u.Pending = pending > 0
1305 u.PendingDM = pendingDM > 0
1306 u.IsAgent = isAgent != 0
1307 if u.IsAgent {
1308 u.Kind = kind
1309 }
1310 out = append(out, u)
1311 }
1312 return out, rows.Err()
1313}
1314
1315// setUserLabelBlob stores a person's display name encrypted under the instance
1316// key and clears any plaintext label, so the server never holds a readable name
1317// for them. Called by the member themselves (they hold the key); bots are never
1318// routed here — they keep their admin-authored plaintext label.
1319func (db *DB) setUserLabelBlob(userID, blob string) error {
1320 _, err := db.sql.Exec("UPDATE users SET label_blob = ?, label = '' WHERE id = ? AND COALESCE(is_agent, 0) = 0", blob, userID)
1321 return err
1322}
1323
1324// backfillUserLabelBlob seals a LEGACY plaintext label (docs/no-plaintext.md):
1325// members whose name predates label_blob keep a readable label at rest until
1326// they next load the app — which an inactive member never does. Any
1327// instance-key holder may seal it for them, but strictly fill-once: people
1328// only, only while the plaintext is still there, and only if no blob exists —
1329// so it can never overwrite a name someone set themselves, and racing or
1330// repeated calls are harmless no-ops. Returns whether this call did the fill.
1331func (db *DB) backfillUserLabelBlob(userID, blob string) (bool, error) {
1332 res, err := db.sql.Exec(
1333 `UPDATE users SET label_blob = ?, label = ''
1334 WHERE id = ? AND COALESCE(is_agent, 0) = 0
1335 AND COALESCE(label_blob, '') = '' AND label != ''`, blob, userID)
1336 if err != nil {
1337 return false, err
1338 }
1339 n, err := res.RowsAffected()
1340 return n > 0, err
1341}
1342
1343// setUserDescriptionBlob stores a person's "about me" text encrypted under the
1344// instance key ("" clears it). Like the label, it's set by the member themselves;
1345// an agent's blurb is the plaintext bot_configs.description instead.
1346func (db *DB) setUserDescriptionBlob(userID, blob string) error {
1347 _, err := db.sql.Exec("UPDATE users SET description_blob = ? WHERE id = ? AND COALESCE(is_agent, 0) = 0", blob, userID)
1348 return err
1349}
1350
1351// setUserNicknamesBlob stores the member's per-viewer name overrides (issue
1352// #577), sealed to their OWN public key — readable only by their devices,
1353// which share the keypair ("" clears it). Optimistic versioning like the
1354// mini-app state slots: baseVersion is what the writer last read; a stale base
1355// writes nothing (ok=false, current version returned) so the caller re-reads,
1356// replays its journaled ops, and retries. baseVersion < 0 skips the check —
1357// the pre-versioning client's unconditional write, kept working on purpose.
1358func (db *DB) setUserNicknamesBlob(userID, blob string, baseVersion int64) (int64, bool, error) {
1359 q := "UPDATE users SET nicknames_blob = ?, nicknames_version = COALESCE(nicknames_version, 0) + 1 WHERE id = ? AND COALESCE(is_agent, 0) = 0"
1360 args := []any{blob, userID}
1361 if baseVersion >= 0 {
1362 q += " AND COALESCE(nicknames_version, 0) = ?"
1363 args = append(args, baseVersion)
1364 }
1365 res, err := db.sql.Exec(q, args...)
1366 if err != nil {
1367 return 0, false, err
1368 }
1369 n, err := res.RowsAffected()
1370 if err != nil {
1371 return 0, false, err
1372 }
1373 _, v, verr := db.userNicknamesBlob(userID) // where the row (or the winner) is now
1374 return v, n > 0, verr
1375}
1376
1377// userNicknamesBlob returns the member's own sealed nickname overrides ("" when
1378// never set) and the blob's version (0 until first write).
1379func (db *DB) userNicknamesBlob(userID string) (string, int64, error) {
1380 var blob string
1381 var version int64
1382 err := db.sql.QueryRow("SELECT COALESCE(nicknames_blob, ''), COALESCE(nicknames_version, 0) FROM users WHERE id = ?", userID).Scan(&blob, &version)
1383 return blob, version, err
1384}
1385
1386// setUserPrefsBlob stores the member's private moderation preferences (muted
1387// people, for now), sealed to their OWN public key like the nicknames blob —
1388// only their devices can open it ("" clears it). Same optimistic versioning:
1389// a stale base writes nothing (ok=false, current version returned); < 0 skips
1390// the check.
1391func (db *DB) setUserPrefsBlob(userID, blob string, baseVersion int64) (int64, bool, error) {
1392 q := "UPDATE users SET prefs_blob = ?, prefs_version = COALESCE(prefs_version, 0) + 1 WHERE id = ? AND COALESCE(is_agent, 0) = 0"
1393 args := []any{blob, userID}
1394 if baseVersion >= 0 {
1395 q += " AND COALESCE(prefs_version, 0) = ?"
1396 args = append(args, baseVersion)
1397 }
1398 res, err := db.sql.Exec(q, args...)
1399 if err != nil {
1400 return 0, false, err
1401 }
1402 n, err := res.RowsAffected()
1403 if err != nil {
1404 return 0, false, err
1405 }
1406 _, v, verr := db.userPrefsBlob(userID) // where the row (or the winner) is now
1407 return v, n > 0, verr
1408}
1409
1410// userPrefsBlob returns the member's own sealed moderation preferences (""
1411// when never set) and the blob's version (0 until first write).
1412func (db *DB) userPrefsBlob(userID string) (string, int64, error) {
1413 var blob string
1414 var version int64
1415 err := db.sql.QueryRow("SELECT COALESCE(prefs_blob, ''), COALESCE(prefs_version, 0) FROM users WHERE id = ?", userID).Scan(&blob, &version)
1416 return blob, version, err
1417}
1418
1419// userCreatedAt returns when a member joined (0 when unknown) — the
1420// moderation brakes use it for new-member probation.
1421func (db *DB) userCreatedAt(id string) int64 {
1422 var t int64
1423 db.sql.QueryRow("SELECT COALESCE(created_at, 0) FROM users WHERE id = ?", id).Scan(&t)
1424 return t
1425}
1426
1427func (db *DB) userExists(id string) bool {
1428 var x int
1429 return db.sql.QueryRow("SELECT 1 FROM users WHERE id = ?", id).Scan(&x) == nil
1430}
1431
1432// createKeyInvite stores a client-wrapped key under a client-derived id.
1433func (db *DB) createKeyInvite(id, userID, wrapped, claimHash string, expiresAt int64) error {
1434 return db.createKeyInviteWithThread(id, userID, wrapped, "", claimHash, expiresAt)
1435}
1436
1437// createKeyInviteWithThread is createKeyInvite that also links the invite to a
1438// pending DM thread — used to re-send a user invite (a fresh link for the same
1439// pending invitee), so a lost link can be replaced without spawning a new chat.
1440func (db *DB) createKeyInviteWithThread(id, userID, wrapped, threadID, claimHash string, expiresAt int64) error {
1441 var exp, tid any
1442 if expiresAt > 0 {
1443 exp = expiresAt
1444 }
1445 if threadID != "" {
1446 tid = threadID
1447 }
1448 _, err := db.sql.Exec(
1449 "INSERT INTO key_invites(id, user_id, wrapped, thread_id, claim_hash, created_at, expires_at) VALUES(?, ?, ?, ?, ?, ?, ?)",
1450 id, userID, wrapped, tid, claimHash, time.Now().Unix(), exp,
1451 )
1452 return err
1453}
1454
1455// createUserInvite sets up an invite that drops the joiner straight into a DM with
1456// the inviter. In one transaction it creates a placeholder user for the invitee
1457// (label = the inviter's suggested name, no public key until they join), a `dm`
1458// thread with the inviter (keyed) and the placeholder (keyless), records the DM
1459// pair, and stores the key-invite whose wrapped payload carries the thread key
1460// encrypted to the invite secret. inviterWrapped is the thread key wrapped to the
1461// inviter's own public key; bootstrapWrapped is it encrypted to the invite secret
1462// (so the joiner can recover it). The server sees only blobs it can't open.
1463// Returns the new thread id and the placeholder user id.
1464func (db *DB) createUserInvite(inviterID, inviteID, labelBlob, bootstrapWrapped, inviterWrapped, claimHash string, expiresAt int64) (string, string, error) {
1465 inviteeID, err := randToken()
1466 if err != nil {
1467 return "", "", err
1468 }
1469 threadID, err := randToken()
1470 if err != nil {
1471 return "", "", err
1472 }
1473 tx, err := db.sql.Begin()
1474 if err != nil {
1475 return "", "", err
1476 }
1477 defer tx.Rollback()
1478 now := time.Now().Unix()
1479 lo, hi := inviterID, inviteeID
1480 if lo > hi {
1481 lo, hi = hi, lo
1482 }
1483 var exp any
1484 if expiresAt > 0 {
1485 exp = expiresAt
1486 }
1487 stmts := []struct {
1488 q string
1489 args []any
1490 }{
1491 {"INSERT INTO users(id, label_blob, created_at) VALUES(?, ?, ?)", []any{inviteeID, labelBlob, now}},
1492 {"INSERT INTO threads(id, kind, name_blob, created_at) VALUES(?, 'dm', NULL, ?)", []any{threadID, now}},
1493 {"INSERT INTO thread_members(thread_id, user_id, created_at) VALUES(?, ?, ?)", []any{threadID, inviterID, now}},
1494 {"INSERT INTO thread_members(thread_id, user_id, created_at) VALUES(?, ?, ?)", []any{threadID, inviteeID, now}},
1495 {"INSERT INTO thread_keys(thread_id, user_id, wrapped) VALUES(?, ?, ?)", []any{threadID, inviterID, inviterWrapped}},
1496 {"INSERT INTO dm_pairs(user_a, user_b, thread_id) VALUES(?, ?, ?)", []any{lo, hi, threadID}},
1497 {"INSERT INTO key_invites(id, user_id, wrapped, thread_id, claim_hash, created_at, expires_at) VALUES(?, ?, ?, ?, ?, ?, ?)", []any{inviteID, inviteeID, bootstrapWrapped, threadID, claimHash, now, exp}},
1498 }
1499 for _, s := range stmts {
1500 if _, err := tx.Exec(s.q, s.args...); err != nil {
1501 return "", "", err
1502 }
1503 }
1504 return threadID, inviteeID, tx.Commit()
1505}
1506
1507type KeyInvite struct {
1508 ID string
1509 UserID string
1510 Wrapped string
1511 ThreadID string // the pending DM this invite drops the joiner into ("" if none)
1512 ClaimHash string // hashToken of the claim secret; "" for pre-migration invites
1513 GrantsAdmin bool // a setup invite: claiming makes the user the (first) admin
1514 TierID string // cohort invite: the access level its claimer joins ("" = none)
1515 Used bool
1516 Expired bool
1517}
1518
1519func (db *DB) getKeyInvite(id string) (KeyInvite, bool, error) {
1520 var ki KeyInvite
1521 var used, exp sql.NullInt64
1522 var threadID, claimHash sql.NullString
1523 var grantsAdmin int
1524 err := db.sql.QueryRow(
1525 "SELECT id, user_id, wrapped, thread_id, claim_hash, COALESCE(grants_admin, 0), COALESCE(tier_id, ''), used_at, expires_at FROM key_invites WHERE id = ?", id,
1526 ).Scan(&ki.ID, &ki.UserID, &ki.Wrapped, &threadID, &claimHash, &grantsAdmin, &ki.TierID, &used, &exp)
1527 if err == sql.ErrNoRows {
1528 return KeyInvite{}, false, nil
1529 }
1530 if err != nil {
1531 return KeyInvite{}, false, err
1532 }
1533 ki.ThreadID = threadID.String
1534 ki.ClaimHash = claimHash.String
1535 ki.GrantsAdmin = grantsAdmin != 0
1536 ki.Used = used.Valid
1537 ki.Expired = exp.Valid && time.Now().Unix() > exp.Int64
1538 return ki, true, nil
1539}
1540
1541// setInviteGrantsAdmin marks an unclaimed invite as a setup invite: whoever
1542// claims it becomes an admin. Only reachable via admin-authed invite creation.
1543func (db *DB) setInviteGrantsAdmin(id string) error {
1544 _, err := db.sql.Exec("UPDATE key_invites SET grants_admin = 1 WHERE id = ? AND used_at IS NULL", id)
1545 return err
1546}
1547
1548// adoptAdminInvite claims an admin-granting invite on an existing member's
1549// account, promoting them to admin without creating a new user. Also marks
1550// setup_complete=true since the adopter is not a fresh first-time user.
1551// Returns ok=false (no error) if the invite is unknown, used, expired, or has invalid claim secret.
1552func (db *DB) adoptAdminInvite(inviteID, existingUserID, claimSecret string) (bool, error) {
1553 ki, found, err := db.getKeyInvite(inviteID)
1554 if err != nil || !found || ki.Used || ki.Expired || !ki.GrantsAdmin {
1555 return false, err
1556 }
1557 if !validClaimSecret(ki.ClaimHash, claimSecret) {
1558 return false, nil
1559 }
1560 // Mark the invite as used, promote the user to admin, and mark setup complete
1561 // (since existing members should not see the setup flow).
1562 tx, err := db.sql.Begin()
1563 if err != nil {
1564 return false, err
1565 }
1566 defer tx.Rollback()
1567 res, err := tx.Exec("UPDATE key_invites SET used_at = ? WHERE id = ? AND used_at IS NULL", time.Now().Unix(), inviteID)
1568 if err != nil {
1569 return false, err
1570 }
1571 if n, _ := res.RowsAffected(); n == 0 {
1572 return false, nil // already claimed
1573 }
1574 if err := setUserRoleTx(tx, existingUserID, "admin"); err != nil {
1575 return false, err
1576 }
1577 // Mark setup as complete so existing members don't see the setup flow.
1578 if err := setConfigTx(tx, "setup_complete", "1"); err != nil {
1579 return false, err
1580 }
1581 return true, tx.Commit()
1582}
1583
1584// claimKeyInvite marks an unused, unexpired invite as used (single-use) and mints
1585// a device token for its user. Returns the plaintext token (shown once). ok=false
1586// (no error) if the invite is unknown, used, expired, or lost a claim race.
1587//
1588// For a user invite (one that bootstraps a pending DM), wrappedSelf is the thread
1589// key wrapped to the joiner's own public key — we store it as their thread_keys
1590// row so they can decrypt the DM immediately. The joiner IS the invite's user
1591// (the placeholder created at invite time now becomes a real, joined person).
1592func (db *DB) claimKeyInvite(id, deviceLabel, wrappedSelf, claimSecret string) (string, bool, error) {
1593 ki, found, err := db.getKeyInvite(id)
1594 if err != nil || !found || ki.Used || ki.Expired {
1595 return "", false, err
1596 }
1597 // Proof of the invite secret: knowing the id (which the server stores) is not
1598 // enough — the claimer must present the preimage of the stored claim_hash.
1599 if !validClaimSecret(ki.ClaimHash, claimSecret) {
1600 return "", false, nil
1601 }
1602 // A user invite (one that bootstraps a pending DM) is for a not-yet-joined
1603 // placeholder. Refuse once that user has joined (has a device token): otherwise a
1604 // second, still-unused invite for them — e.g. a re-send whose earlier link was
1605 // already used — could mint a token AS them and overwrite the DM key wrapped to
1606 // their real keypair, taking over the account and bricking their chat.
1607 if ki.ThreadID != "" && db.deviceCount(ki.UserID) > 0 {
1608 return "", false, nil
1609 }
1610 res, err := db.sql.Exec("UPDATE key_invites SET used_at = ? WHERE id = ? AND used_at IS NULL", time.Now().Unix(), id)
1611 if err != nil {
1612 return "", false, err
1613 }
1614 if n, _ := res.RowsAffected(); n == 0 {
1615 return "", false, nil // already claimed
1616 }
1617 // First device = this person is entering the space (an add-device claim
1618 // has devices already). Checked before the token insert below creates one.
1619 firstDevice := db.deviceCount(ki.UserID) == 0
1620 tok, err := randToken()
1621 if err != nil {
1622 return "", false, err
1623 }
1624 _, err = db.sql.Exec(
1625 "INSERT INTO device_tokens(token_hash, user_id, label, created_at) VALUES(?, ?, ?, ?)",
1626 hashToken(tok), ki.UserID, deviceLabel, time.Now().Unix(),
1627 )
1628 if err != nil {
1629 return "", false, err
1630 }
1631 if firstDevice {
1632 // Land the newcomer in every default open group. Best-effort ON
1633 // PURPOSE: used_at is already burned above, so failing the claim here
1634 // would brick the invite — and a missed default join is recoverable
1635 // (the group is one Discover tap away).
1636 _ = db.joinDefaultThreads(ki.UserID)
1637 // A cohort invite also carries an access level — assign it now
1638 // ('invite' source), same best-effort rule. Their level key follows
1639 // once they publish a pubkey (setPubkey prods the holders).
1640 if ki.TierID != "" && db.tierExists(ki.TierID) {
1641 _ = db.assignTier(ki.TierID, ki.UserID, "invite")
1642 }
1643 }
1644 if ki.ThreadID != "" && wrappedSelf != "" {
1645 // They're already a member of the pending DM (added at invite time); this
1646 // records the key wrapped to the fresh keypair they just generated.
1647 if err := db.addThreadMember(ki.ThreadID, ki.UserID, wrappedSelf); err != nil {
1648 return "", false, err
1649 }
1650 // Invite lineage (issue #578): the pending DM's other member is who
1651 // invited them — recorded so moderation can see a member's way in.
1652 if inviter, ok := db.otherThreadMember(ki.ThreadID, ki.UserID); ok {
1653 _, _ = db.sql.Exec("UPDATE users SET invited_by = ? WHERE id = ? AND COALESCE(invited_by, '') = ''", inviter, ki.UserID)
1654 }
1655 }
1656 if ki.GrantsAdmin {
1657 // A setup invite: its claimer is the (first) admin. Role is authorisation
1658 // only — no key material rides on it.
1659 if err := db.setUserRole(ki.UserID, "admin"); err != nil {
1660 return "", false, err
1661 }
1662 }
1663 return tok, true, nil
1664}
1665
1666// userRole returns a user's role ('member' when unset or unknown).
1667func (db *DB) userRole(id string) string {
1668 var r string
1669 if db.sql.QueryRow("SELECT COALESCE(role, 'member') FROM users WHERE id = ?", id).Scan(&r) != nil || r == "" {
1670 return "member"
1671 }
1672 return r
1673}
1674
1675// adminUserIDs returns the ids of every role-admin — the recipients of
1676// changelog announcements (announce.go).
1677func (db *DB) adminUserIDs() ([]string, error) {
1678 rows, err := db.sql.Query("SELECT id FROM users WHERE COALESCE(role, 'member') = 'admin'")
1679 if err != nil {
1680 return nil, err
1681 }
1682 defer rows.Close()
1683 var out []string
1684 for rows.Next() {
1685 var id string
1686 if err := rows.Scan(&id); err != nil {
1687 return nil, err
1688 }
1689 out = append(out, id)
1690 }
1691 return out, rows.Err()
1692}
1693
1694// adminCount reports how many members hold the admin role. Removed users are
1695// hard-deleted rows, so this is the live count — 1 means the caller (if admin)
1696// holds the only admin key on the instance.
1697func (db *DB) adminCount() int {
1698 var n int
1699 db.sql.QueryRow("SELECT COUNT(*) FROM users WHERE role = 'admin'").Scan(&n)
1700 return n
1701}
1702
1703func (db *DB) setUserRole(id, role string) error {
1704 _, err := db.sql.Exec("UPDATE users SET role = ? WHERE id = ?", role, id)
1705 return err
1706}
1707
1708// setUserRoleTx sets a user's role within a transaction (for atomic operations).
1709func setUserRoleTx(tx *sql.Tx, id, role string) error {
1710 _, err := tx.Exec("UPDATE users SET role = ? WHERE id = ?", role, id)
1711 return err
1712}
1713
1714// deviceCount returns how many devices (tokens) a user currently has signed in — so
1715// the client can warn before signing out the LAST one (which loses their keys).
1716func (db *DB) deviceCount(userID string) int {
1717 var n int
1718 db.sql.QueryRow("SELECT COUNT(*) FROM device_tokens WHERE user_id = ?", userID).Scan(&n)
1719 return n
1720}
1721
1722// deleteDeviceToken revokes a single device token (signing out on that device).
1723func (db *DB) deleteDeviceToken(token string) error {
1724 _, err := db.sql.Exec("DELETE FROM device_tokens WHERE token_hash = ?", hashToken(token))
1725 return err
1726}
1727
1728func (db *DB) deviceTokenValid(token string) bool {
1729 var x int
1730 return db.sql.QueryRow("SELECT 1 FROM device_tokens WHERE token_hash = ?", hashToken(token)).Scan(&x) == nil
1731}
1732
1733// deviceTokenUser returns the user a device token belongs to (for self-service
1734// "add a device"). ok=false for unknown or legacy (non-device) tokens.
1735func (db *DB) deviceTokenUser(token string) (string, bool) {
1736 return db.deviceTokenUserByHash(hashToken(token))
1737}
1738
1739// deviceTokenUserByHash resolves the owning user from a token hash, so callers
1740// that only hold the hash (push/APNs subscriptions) never need the raw token.
1741func (db *DB) deviceTokenUserByHash(tokenHash string) (string, bool) {
1742 var uid string
1743 if db.sql.QueryRow("SELECT user_id FROM device_tokens WHERE token_hash = ?", tokenHash).Scan(&uid) != nil {
1744 return "", false
1745 }
1746 return uid, true
1747}
1748
1749// ---- roster + threads (see docs/threads-and-dms.md) ---------------------
1750
1751// RosterEntry is one discoverable person: their label + ECDH public key (used to
1752// wrap thread/instance keys to them) + their profile avatar blob id (if set).
1753type RosterEntry struct {
1754 UserID string `json:"user_id"`
1755 // Label is a plaintext name — only ever set for bots (admin-authored). For a
1756 // person it's empty; their name is in LabelBlob, encrypted under the instance
1757 // key, and the client decrypts it. (Un-migrated people may still have a
1758 // plaintext Label until their client republishes it encrypted — see setUserLabelBlob.)
1759 Label string `json:"label"`
1760 LabelBlob string `json:"label_blob,omitempty"`
1761 PublicKey string `json:"public_key"`
1762 // Key transparency (#573): the joiner's HMAC of PublicKey under HKDF(invite
1763 // secret T). A verifier holding T recomputes it (crypto.js verifyBindMac) to
1764 // confirm the server didn't substitute its own key. "" for pre-KT members.
1765 BindMAC string `json:"bind_mac,omitempty"`
1766 Avatar string `json:"avatar,omitempty"`
1767 // The photo's alt text, encrypted under the instance key (client decrypts).
1768 AvatarAltBlob string `json:"avatar_alt_blob,omitempty"`
1769 // A person's free-text "about me", encrypted under the instance key (client
1770 // decrypts). Served only to their connections, like the rest of the entry.
1771 DescriptionBlob string `json:"description_blob,omitempty"`
1772 // admin | moderator | member — lets clients say who runs the space (the
1773 // welcome conversation's "run by <admin>"). Server-known metadata, not
1774 // content; it already gates the admin API.
1775 Role string `json:"role,omitempty"`
1776 IsAgent bool `json:"is_agent,omitempty"` // an agent/bot, not a person
1777 // One-line admin-authored blurb for agents (the Helpers catalog entry —
1778 // doubles as the consent hint). Always "" for people.
1779 Description string `json:"description,omitempty"`
1780 // Kind is "ai" (LLM-backed) or "bot" (deterministic) for agents; "" for
1781 // people. Drives the AI/BOT badge — the honest "where do my words go?" cue.
1782 Kind string `json:"kind,omitempty"`
1783 // Presence: "online" | "away" | "offline". Scanned as the raw pinned flag
1784 // ('' or 'away'); the roster handler overwrites it with the effective state
1785 // (stampPresence, presence.go) before it leaves the server.
1786 Presence string `json:"presence,omitempty"`
1787}
1788
1789func (db *DB) setUserPublicKey(userID, pub, bindMac string) error {
1790 // Transactional so the key transparency 'join' event can't be missing for a
1791 // member who's now keyed — a gap would read as roster tampering to a
1792 // verifier, exactly like a missing message journal row.
1793 tx, err := db.sql.Begin()
1794 if err != nil {
1795 return err
1796 }
1797 defer tx.Rollback()
1798 var cur string
1799 tx.QueryRow("SELECT COALESCE(public_key, '') FROM users WHERE id = ?", userID).Scan(&cur)
1800 // First key for this member (empty → set): store the pubkey AND its
1801 // key-transparency bind-MAC, and journal the 'join'. The bind-MAC is written
1802 // ONLY here and never overwritten, so an add-device re-register of the same
1803 // key can't drop (or replace) the original binding (#573).
1804 if cur == "" && pub != "" {
1805 if _, err := tx.Exec("UPDATE users SET public_key = ?, bind_mac = ? WHERE id = ?", pub, bindMac, userID); err != nil {
1806 return err
1807 }
1808 if err := appendRosterEventTx(tx, time.Now().Unix(), "join", userID, ktPubHash(pub)); err != nil {
1809 return err
1810 }
1811 } else {
1812 // Re-registering the same key (add-device) — keep the existing bind_mac.
1813 if _, err := tx.Exec("UPDATE users SET public_key = ? WHERE id = ?", pub, userID); err != nil {
1814 return err
1815 }
1816 }
1817 return tx.Commit()
1818}
1819
1820// appendRosterEventTx journals a key-transparency roster event (docs/tamper-
1821// evidence.md slice 4): membership changes are committed to the signed
1822// checkpoint chain like message events, so a monitor catches a server that
1823// rewrites the roster. The journal's generic columns carry the kind-scoped
1824// detail: thread_id = the member's user id, blob_hash = sha256 of their public
1825// key ('join') or "" ('remove'); msg_id is unused (0).
1826func appendRosterEventTx(tx *sql.Tx, ts int64, kind, userID, pubHash string) error {
1827 return appendJournalTx(tx, ts, kind, 0, userID, pubHash)
1828}
1829
1830// ktPubHash commits to a public key without storing it twice — sha256 hex of
1831// the key string, the same shape ciphertextHash uses for message blobs.
1832func ktPubHash(pub string) string {
1833 sum := sha256.Sum256([]byte(pub))
1834 return hex.EncodeToString(sum[:])
1835}
1836
1837// userPublicKey returns a user's registered ECDH public key ("" if not set yet).
1838func (db *DB) userPublicKey(id string) string {
1839 var pk sql.NullString
1840 db.sql.QueryRow("SELECT public_key FROM users WHERE id = ?", id).Scan(&pk)
1841 return pk.String
1842}
1843
1844// setUserPresence pins ('away') or unpins (”) a user's presence.
1845func (db *DB) setUserPresence(userID, presence string) error {
1846 _, err := db.sql.Exec("UPDATE users SET presence = ? WHERE id = ?", presence, userID)
1847 return err
1848}
1849
1850// userPresenceInfo returns the pinned presence flag plus whether the user is
1851// an agent — the two per-user inputs to presenceState (presence.go).
1852func (db *DB) userPresenceInfo(userID string) (pinned string, isAgent bool) {
1853 var agent int
1854 db.sql.QueryRow("SELECT COALESCE(presence, ''), COALESCE(is_agent, 0) FROM users WHERE id = ?", userID).Scan(&pinned, &agent)
1855 return pinned, agent != 0
1856}
1857
1858// removeUser deletes a user and all their traces (admin roster management): device
1859// tokens, thread membership + wrapped keys, DM-pair records, pending invites, their
1860// instance-key copy, and their own messages — then cleans up any thread
1861// left with no members. Threads they merely shared (a DM/group with others) survive;
1862// the other members keep them.
1863func (db *DB) removeUser(id string) error {
1864 tx, err := db.sql.Begin()
1865 if err != nil {
1866 return err
1867 }
1868 defer tx.Rollback()
1869
1870 // The threads the user is in — used to scope the orphan sweep and to find any
1871 // pending DMs they set up. (Collect fully before issuing more statements: one
1872 // SQLite connection can't interleave an open cursor with writes.)
1873 rows, err := tx.Query("SELECT thread_id FROM thread_members WHERE user_id = ?", id)
1874 if err != nil {
1875 return err
1876 }
1877 var threadIDs []string
1878 for rows.Next() {
1879 var t string
1880 if err := rows.Scan(&t); err != nil {
1881 rows.Close()
1882 return err
1883 }
1884 threadIDs = append(threadIDs, t)
1885 }
1886 rows.Close()
1887 if err := rows.Err(); err != nil {
1888 return err
1889 }
1890
1891 teardown := func(tid string) error {
1892 // Journal the messages this teardown removes (tamper evidence) before
1893 // they're gone — same transaction, so the trail can't drift.
1894 if err := journalMessageDeletesTx(tx, "thread_id = ?", tid); err != nil {
1895 return err
1896 }
1897 for _, q := range []string{
1898 "DELETE FROM key_invites WHERE thread_id = ?",
1899 "DELETE FROM thread_keys WHERE thread_id = ?",
1900 "DELETE FROM thread_members WHERE thread_id = ?",
1901 "DELETE FROM dm_pairs WHERE thread_id = ?",
1902 "DELETE FROM messages WHERE thread_id = ?",
1903 "DELETE FROM threads WHERE id = ?",
1904 } {
1905 if _, err := tx.Exec(q, tid); err != nil {
1906 return err
1907 }
1908 }
1909 return nil
1910 }
1911
1912 // Pending DMs this user set up whose invitee never joined: tear the whole thing
1913 // down and delete the keyless placeholder, so the invitee's still-live link can't
1914 // drop them into a dead, unreadable one-person chat.
1915 for _, tid := range threadIDs {
1916 var invitee string
1917 err := tx.QueryRow("SELECT user_id FROM key_invites WHERE thread_id = ? LIMIT 1", tid).Scan(&invitee)
1918 if err == sql.ErrNoRows {
1919 continue
1920 }
1921 if err != nil {
1922 return err
1923 }
1924 var joined int
1925 if err := tx.QueryRow("SELECT COUNT(*) FROM device_tokens WHERE user_id = ?", invitee).Scan(&joined); err != nil {
1926 return err
1927 }
1928 if joined > 0 {
1929 continue // the invitee has joined; leave their real chat alone
1930 }
1931 if err := teardown(tid); err != nil {
1932 return err
1933 }
1934 if invitee != id {
1935 if _, err := tx.Exec("DELETE FROM users WHERE id = ? AND (public_key IS NULL OR public_key = '')", invitee); err != nil {
1936 return err
1937 }
1938 }
1939 }
1940
1941 // The user's own rows. Their messages get journaled first (tamper
1942 // evidence) — removal deletes them below, and the trail must say so.
1943 if err := journalMessageDeletesTx(tx, "author_hash = ?", id); err != nil {
1944 return err
1945 }
1946 // A 'remove' key-transparency event, so the roster change is committed to
1947 // the chain like the member's join was (docs/tamper-evidence.md slice 4).
1948 if err := appendRosterEventTx(tx, time.Now().Unix(), "remove", id, ""); err != nil {
1949 return err
1950 }
1951 // Tombstone each doomed message (#765, same mechanism as deleteMessage's
1952 // #581): members' devices that already hold these messages learn to drop
1953 // them from ordinary forward catch-up — a purge is exactly when that
1954 // propagation matters most. Skipped for legacy NULL-thread rows
1955 // (unreachable by thread reads); the fresh tombstones have a NULL
1956 // author_hash, so the cascade's DELETE below can't touch them. Threads
1957 // torn down whole (above) need none — the thread's disappearance already
1958 // clears every client's copy.
1959 purgeNow := time.Now().Unix()
1960 if _, err := tx.Exec(
1961 "INSERT INTO messages(created_at, blob, thread_id, tombstone_of) SELECT ?, '', thread_id, id FROM messages WHERE author_hash = ? AND thread_id IS NOT NULL",
1962 purgeNow, id,
1963 ); err != nil {
1964 return err
1965 }
1966 // Each tombstone is a real message row (clients replay it from catch-up),
1967 // so each gets a journal row like any other insert — an unexplained row
1968 // would read as tampering to a verifier. Set-based like the insert above:
1969 // the fresh tombstones are exactly those pointing at a still-present row
1970 // of the purged author (deleteMessage's older tombstones point at ids
1971 // already gone, so the join can't double-journal them).
1972 if _, err := tx.Exec(
1973 "INSERT INTO event_journal(ts, kind, msg_id, thread_id, blob_hash) "+
1974 "SELECT ?, 'msg', t.id, COALESCE(t.thread_id, ''), ? FROM messages t "+
1975 "JOIN messages d ON d.id = t.tombstone_of AND d.author_hash = ?",
1976 purgeNow, ciphertextHash(""), id,
1977 ); err != nil {
1978 return err
1979 }
1980 for _, s := range []struct {
1981 q string
1982 arg []any
1983 }{
1984 {"DELETE FROM thread_keys WHERE user_id = ?", []any{id}},
1985 {"DELETE FROM thread_members WHERE user_id = ?", []any{id}},
1986 // APNs tokens hang off device-session tokens (matched by token hash) —
1987 // resolve them BEFORE the device_tokens rows below are gone.
1988 {"DELETE FROM apns_tokens WHERE token_hash IN (SELECT token_hash FROM device_tokens WHERE user_id = ?)", []any{id}},
1989 {"DELETE FROM device_tokens WHERE user_id = ?", []any{id}},
1990 {"DELETE FROM key_invites WHERE user_id = ?", []any{id}},
1991 {"DELETE FROM instance_keys WHERE user_id = ?", []any{id}},
1992 // Access levels: their memberships and key copies go too — a removed
1993 // member must not linger as a ghost holder (which would block the
1994 // "key lost" detection) or a ghost member (wrong counts, a forever
1995 // "keys on their way" line in the Admin app).
1996 {"DELETE FROM user_tiers WHERE user_id = ?", []any{id}},
1997 {"DELETE FROM tier_keys WHERE user_id = ?", []any{id}},
1998 {"DELETE FROM dm_pairs WHERE user_a = ? OR user_b = ?", []any{id, id}},
1999 {"DELETE FROM messages WHERE author_hash = ?", []any{id}},
2000 {"DELETE FROM scheduled_messages WHERE author_id = ?", []any{id}},
2001 {"DELETE FROM push_subscriptions WHERE user_id = ?", []any{id}},
2002 {"DELETE FROM users WHERE id = ?", []any{id}},
2003 } {
2004 if _, err := tx.Exec(s.q, s.arg...); err != nil {
2005 return err
2006 }
2007 }
2008
2009 // Sweep only the user's former threads that now have no members left (e.g. their
2010 // "Notes" thread, or a DM whose other member was already gone).
2011 for _, tid := range threadIDs {
2012 var n int
2013 if err := tx.QueryRow("SELECT COUNT(*) FROM thread_members WHERE thread_id = ?", tid).Scan(&n); err != nil {
2014 return err
2015 }
2016 if n == 0 {
2017 if err := teardown(tid); err != nil {
2018 return err
2019 }
2020 }
2021 }
2022 return tx.Commit()
2023}
2024
2025// setUserLabel renames a user in the roster (admin roster management — e.g.
2026// renaming a bot). Labels are display names, not identity: keys, tokens, and
2027// message history are untouched.
2028func (db *DB) setUserLabel(userID, label string) error {
2029 res, err := db.sql.Exec("UPDATE users SET label = ? WHERE id = ?", label, userID)
2030 if err != nil {
2031 return err
2032 }
2033 if n, _ := res.RowsAffected(); n == 0 {
2034 return sql.ErrNoRows
2035 }
2036 return nil
2037}
2038
2039// setUserAgent flags a user as an agent (bot) so the UI can badge it. On its
2040// own it confers no privilege; combined with available_to_members it makes the
2041// bot a vetted helper (catalog-visible, full-roster reads — see isVettedAgent).
2042func (db *DB) setUserAgent(userID string) error {
2043 _, err := db.sql.Exec("UPDATE users SET is_agent = 1 WHERE id = ?", userID)
2044 return err
2045}
2046
2047// isAgent reports whether a user is a bot (the admin bot-config endpoints
2048// refuse to attach config to a person).
2049func (db *DB) isAgent(userID string) bool {
2050 var n int
2051 db.sql.QueryRow("SELECT COALESCE(is_agent, 0) FROM users WHERE id = ?", userID).Scan(&n)
2052 return n != 0
2053}
2054
2055// isVettedAgent reports whether a user is an agent the admin marked available
2056// to members. Vetted bots are instance-wide infrastructure: they appear in
2057// everyone's Helpers catalog and read the full joined roster (so e.g. Eleven
2058// can welcome a brand-new member it shares no thread with).
2059func (db *DB) isVettedAgent(userID string) bool {
2060 var n int
2061 db.sql.QueryRow(`
2062 SELECT COUNT(*) FROM users u JOIN bot_configs b ON b.user_id = u.id
2063 WHERE u.id = ? AND COALESCE(u.is_agent, 0) = 1 AND COALESCE(b.available_to_members, 0) = 1`,
2064 userID).Scan(&n)
2065 return n > 0
2066}
2067
2068// BotConfig is an agent's console-managed settings (see bot_configs).
2069type BotConfig struct {
2070 Model string `json:"model"`
2071 SystemPrompt string `json:"system_prompt"`
2072 Docs string `json:"docs"`
2073 Listen string `json:"listen"` // ''/'mention' = @mention only, 'name' = also bare name
2074 Welcome string `json:"welcome"` // admin-tweakable welcome DM (the guide uses it); "" = shipped default
2075 // The instance-wide AI gate, not a bot_configs column — getBotConfig fills
2076 // it from config so the sidecar learns a flip on its next (re)connect.
2077 AIEnabled bool `json:"ai_enabled"`
2078}
2079
2080// setBotKind records an agent's kind ("ai" | "bot"), self-declared by the sidecar
2081// on connect (LLM-backed vs deterministic). A narrow upsert: it touches only the
2082// kind column, so it never clobbers admin-edited config — and setBotConfig never
2083// clobbers kind. Order-independent.
2084func (db *DB) setBotKind(userID, kind string) error {
2085 _, err := db.sql.Exec(
2086 `INSERT INTO bot_configs(user_id, kind, updated_at) VALUES(?, ?, ?)
2087 ON CONFLICT(user_id) DO UPDATE SET kind = excluded.kind, updated_at = excluded.updated_at`,
2088 userID, kind, time.Now().Unix())
2089 return err
2090}
2091
2092// setBotWelcome stores the admin-tweakable welcome DM text (same narrow-upsert
2093// discipline as setBotKind, so it composes with setBotConfig without a signature
2094// change or clobbering other fields).
2095func (db *DB) setBotWelcome(userID, welcome string) error {
2096 _, err := db.sql.Exec(
2097 `INSERT INTO bot_configs(user_id, welcome, updated_at) VALUES(?, ?, ?)
2098 ON CONFLICT(user_id) DO UPDATE SET welcome = excluded.welcome, updated_at = excluded.updated_at`,
2099 userID, welcome, time.Now().Unix())
2100 return err
2101}
2102
2103func (db *DB) setBotConfig(userID, model, systemPrompt, docs, listen, description string, available bool) error {
2104 av := 0
2105 if available {
2106 av = 1
2107 }
2108 _, err := db.sql.Exec(
2109 `INSERT INTO bot_configs(user_id, model, system_prompt, docs, listen, description, available_to_members, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?)
2110 ON CONFLICT(user_id) DO UPDATE SET model = excluded.model, system_prompt = excluded.system_prompt, docs = excluded.docs,
2111 listen = excluded.listen, description = excluded.description, available_to_members = excluded.available_to_members, updated_at = excluded.updated_at`,
2112 userID, model, systemPrompt, docs, listen, description, av, time.Now().Unix(),
2113 )
2114 return err
2115}
2116
2117// botConfig returns an agent's config (ok=false if none has been set).
2118func (db *DB) botConfig(userID string) (BotConfig, bool, error) {
2119 var c BotConfig
2120 err := db.sql.QueryRow("SELECT model, system_prompt, docs, listen, COALESCE(welcome, '') FROM bot_configs WHERE user_id = ?", userID).
2121 Scan(&c.Model, &c.SystemPrompt, &c.Docs, &c.Listen, &c.Welcome)
2122 if err == sql.ErrNoRows {
2123 return c, false, nil
2124 }
2125 return c, err == nil, err
2126}
2127
2128// FullBotConfig is BotConfig plus the Helpers-catalog metadata — what the
2129// admin console edits (the sidecar's own view stays BotConfig).
2130type FullBotConfig struct {
2131 BotConfig
2132 Description string `json:"description"`
2133 Available bool `json:"available_to_members"`
2134}
2135
2136// fullBotConfig returns everything the console shows for a bot; ok=false when
2137// no config row exists yet (a pre-console bot — fields default to empty).
2138func (db *DB) fullBotConfig(userID string) (FullBotConfig, bool, error) {
2139 var c FullBotConfig
2140 var av int
2141 err := db.sql.QueryRow(
2142 `SELECT model, system_prompt, docs, COALESCE(listen, ''), COALESCE(welcome, ''), COALESCE(description, ''), COALESCE(available_to_members, 0)
2143 FROM bot_configs WHERE user_id = ?`, userID).
2144 Scan(&c.Model, &c.SystemPrompt, &c.Docs, &c.Listen, &c.Welcome, &c.Description, &av)
2145 if err == sql.ErrNoRows {
2146 return c, false, nil
2147 }
2148 c.Available = av != 0
2149 return c, err == nil, err
2150}
2151
2152// setAvatar sets the photo and its (encrypted) alt text together — a photo
2153// change always re-states the alt, so a swap never keeps a stale description.
2154func (db *DB) setAvatar(userID, blobID, altBlob string) error {
2155 var b, a any
2156 if blobID != "" {
2157 b = blobID
2158 if altBlob != "" {
2159 a = altBlob
2160 }
2161 }
2162 _, err := db.sql.Exec("UPDATE users SET avatar_blob = ?, avatar_alt_blob = ? WHERE id = ?", b, a, userID)
2163 return err
2164}
2165
2166// roster lists everyone who has joined (registered a public key) — the full
2167// directory. Members no longer see this; they get connections() (self + people
2168// they share a thread with). Kept for admin/back-end use and tests.
2169func (db *DB) roster() ([]RosterEntry, error) {
2170 rows, err := db.sql.Query(
2171 `SELECT u.id, u.label, COALESCE(u.label_blob, ''), u.public_key, COALESCE(u.avatar_blob, ''), COALESCE(u.avatar_alt_blob, ''), COALESCE(u.description_blob, ''), COALESCE(u.role, 'member'), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai'), COALESCE(u.presence, ''), COALESCE(u.bind_mac, '')
2172 FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id
2173 WHERE u.public_key IS NOT NULL AND u.public_key <> '' ORDER BY u.label`,
2174 )
2175 if err != nil {
2176 return nil, err
2177 }
2178 return scanRoster(rows)
2179}
2180
2181// connections is the roster a member actually sees: themselves plus everyone they
2182// already share a thread with (a DM or a group). It intentionally does NOT expose
2183// the whole instance — you can only start a new chat with someone you're already
2184// connected to, or by inviting someone new. Keyless people (a pending invitee who
2185// hasn't joined yet) are included so a pending DM can still show their name; the
2186// client omits keyless entries from the "start a new chat" picker.
2187//
2188// The one exemption is the Helpers catalog (docs/bots.md): agents the admin has
2189// marked available_to_members are shown to everyone — bots share no thread when
2190// new, so they can't ride the connections rule for discovery. People stay
2191// connections-only; only vetted, clearly-badged helpers are instance-visible.
2192func (db *DB) connections(userID string) ([]RosterEntry, error) {
2193 rows, err := db.sql.Query(`
2194 SELECT u.id, u.label, COALESCE(u.label_blob, ''), COALESCE(u.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.avatar_alt_blob, ''), COALESCE(u.description_blob, ''), COALESCE(u.role, 'member'), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai'), COALESCE(u.presence, ''), COALESCE(u.bind_mac, '')
2195 FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id
2196 WHERE u.id = ?
2197 OR u.id IN (
2198 SELECT m2.user_id FROM thread_members m1
2199 JOIN thread_members m2 ON m2.thread_id = m1.thread_id
2200 WHERE m1.user_id = ? AND m2.user_id <> ?
2201 )
2202 OR (COALESCE(u.is_agent, 0) = 1 AND COALESCE(b.available_to_members, 0) = 1
2203 AND u.public_key IS NOT NULL AND u.public_key <> '')
2204 ORDER BY u.label`, userID, userID, userID)
2205 if err != nil {
2206 return nil, err
2207 }
2208 return scanRoster(rows)
2209}
2210
2211func scanRoster(rows *sql.Rows) ([]RosterEntry, error) {
2212 defer rows.Close()
2213 out := []RosterEntry{}
2214 for rows.Next() {
2215 var e RosterEntry
2216 var isAgent int
2217 var kind string
2218 if err := rows.Scan(&e.UserID, &e.Label, &e.LabelBlob, &e.PublicKey, &e.Avatar, &e.AvatarAltBlob, &e.DescriptionBlob, &e.Role, &isAgent, &e.Description, &kind, &e.Presence, &e.BindMAC); err != nil {
2219 return nil, err
2220 }
2221 e.IsAgent = isAgent != 0
2222 if e.IsAgent {
2223 e.Kind = kind // "ai" | "bot" — badge cue; only meaningful for agents
2224 } else {
2225 e.Description = "" // description is Helpers-catalog metadata, agents only
2226 }
2227 out = append(out, e)
2228 }
2229 return out, rows.Err()
2230}
2231
2232// ---- instance key (shared key for all-members content, e.g. avatars) ----
2233
2234// instanceKeyFor returns the caller's wrapped copy of the instance key (if any).
2235func (db *DB) instanceKeyFor(userID string) (string, bool, error) {
2236 var w string
2237 err := db.sql.QueryRow("SELECT wrapped FROM instance_keys WHERE user_id = ?", userID).Scan(&w)
2238 if err == sql.ErrNoRows {
2239 return "", false, nil
2240 }
2241 return w, err == nil, err
2242}
2243
2244// instanceKeyHolders lists user ids that already hold a wrapped instance key, so
2245// a member who has it only re-wraps to those who don't (bootstrap + reconcile).
2246func (db *DB) instanceKeyHolders() ([]string, error) {
2247 rows, err := db.sql.Query("SELECT user_id FROM instance_keys")
2248 if err != nil {
2249 return nil, err
2250 }
2251 defer rows.Close()
2252 out := []string{}
2253 for rows.Next() {
2254 var u string
2255 if err := rows.Scan(&u); err != nil {
2256 return nil, err
2257 }
2258 out = append(out, u)
2259 }
2260 return out, rows.Err()
2261}
2262
2263// lastReadActivity is the newest read watermark across a set of users — the
2264// durable "when were they last around" proxy (#785): read marks persist and
2265// advance with any real use, unlike the in-memory presence map that resets on
2266// restart. 0 when none of them ever read anything.
2267func (db *DB) lastReadActivity(userIDs []string) (int64, error) {
2268 if len(userIDs) == 0 {
2269 return 0, nil
2270 }
2271 args := make([]any, len(userIDs))
2272 marks := make([]string, len(userIDs))
2273 for i, u := range userIDs {
2274 args[i] = u
2275 marks[i] = "?"
2276 }
2277 var at sql.NullInt64
2278 err := db.sql.QueryRow(
2279 "SELECT MAX(last_read_at) FROM read_marks WHERE user_id IN ("+strings.Join(marks, ",")+")",
2280 args...,
2281 ).Scan(&at)
2282 if err != nil {
2283 return 0, err
2284 }
2285 return at.Int64, nil
2286}
2287
2288// resetInstanceKey is the admin break-glass for an ORPHANED instance key
2289// (#785): every holder gone for good, so no one can ever be granted it and
2290// every newer member stays keyless. Dropping all holder rows lets the next
2291// member to boot mint a fresh key (setupInstanceKey's bootstrap branch) and
2292// grant it outward as people come online. The old key's sealed blobs are
2293// unreadable forever, so this also clears what can regrow and demotes what
2294// can't:
2295// - members' sealed name/photo/about columns → NULL (each republishes their
2296// name on next load; photos/abouts are re-entered)
2297// - open groups gated under the instance key → invite-only, open_key
2298// dropped (members keep their thread keys; an admin can re-open the
2299// group afterwards, which wraps its key under the NEW instance key)
2300// - member apps/bots stay as rows but skip as undecryptable — deleting
2301// user content outright is worse than leaving it inert
2302// - the space thread (spacethread.go) is deleted outright: unlike an open
2303// group, its members hold NO per-member key wraps — open_key under the
2304// dead instance key was the ONLY path to its key, so its whole log is
2305// unreadable forever. The next space-wide install mints a fresh one.
2306//
2307// The server never sees any key either way — this deletes wrapped blobs it
2308// could never open.
2309func (db *DB) resetInstanceKey() error {
2310 tx, err := db.sql.Begin()
2311 if err != nil {
2312 return err
2313 }
2314 defer tx.Rollback()
2315 // Resolve the space thread INSIDE the transaction: a concurrent
2316 // POST /api/space-thread landing between an outside read and the commit
2317 // would leave a thread wrapped under the key we're about to orphan.
2318 spaceID, err := deleteSpaceThreadTx(tx)
2319 if err != nil {
2320 return err
2321 }
2322 for _, q := range []string{
2323 "DELETE FROM instance_keys",
2324 "UPDATE users SET label_blob = NULL, avatar_blob = NULL, avatar_alt_blob = NULL, description_blob = NULL",
2325 "UPDATE threads SET visibility = 'invite', open_key = NULL, default_join = 0 WHERE visibility = 'open' AND (open_tier IS NULL OR open_tier = '')",
2326 } {
2327 if _, err := tx.Exec(q); err != nil {
2328 return err
2329 }
2330 }
2331 if err := tx.Commit(); err != nil {
2332 return err
2333 }
2334 if spaceID != "" {
2335 // The thread's attachment blobs (bytes on disk) go with it.
2336 return db.deleteThreadBlobs(spaceID)
2337 }
2338 return nil
2339}
2340
2341// deleteSpaceThreadTx removes the space thread and its rows inside tx,
2342// returning its id ("" when none existed) so the caller can reclaim its blob
2343// bytes after commit. Shared by resetInstanceKey (whose reset orphans the
2344// thread's only key path) and the scoped admin space-thread reset — the lever
2345// for a poisoned open_key that shouldn't cost every member their name/photo.
2346func deleteSpaceThreadTx(tx *sql.Tx) (string, error) {
2347 var spaceID string
2348 err := tx.QueryRow("SELECT id FROM threads WHERE kind = 'space'").Scan(&spaceID)
2349 if err == sql.ErrNoRows {
2350 return "", nil
2351 }
2352 if err != nil {
2353 return "", err
2354 }
2355 // Journal the message deletes like every other hard delete (tamper
2356 // evidence), then drop the thread's rows — including thread_keys, which an
2357 // explicitly-added bot may have (mirroring deleteThread's teardown).
2358 if err := journalMessageDeletesTx(tx, "thread_id = ?", spaceID); err != nil {
2359 return "", err
2360 }
2361 for _, q := range []string{
2362 "DELETE FROM messages WHERE thread_id = ?",
2363 "DELETE FROM thread_state WHERE thread_id = ?",
2364 "DELETE FROM scheduled_messages WHERE thread_id = ?",
2365 "DELETE FROM thread_keys WHERE thread_id = ?",
2366 "DELETE FROM thread_members WHERE thread_id = ?",
2367 "DELETE FROM threads WHERE id = ?",
2368 } {
2369 if _, err := tx.Exec(q, spaceID); err != nil {
2370 return "", err
2371 }
2372 }
2373 return spaceID, nil
2374}
2375
2376// resetSpaceThread is the scoped teardown behind the admin space-thread
2377// reset: just the space thread, nothing else — for recovering from a
2378// poisoned/undecryptable open_key without the full instance-key break-glass.
2379func (db *DB) resetSpaceThread() error {
2380 tx, err := db.sql.Begin()
2381 if err != nil {
2382 return err
2383 }
2384 defer tx.Rollback()
2385 spaceID, err := deleteSpaceThreadTx(tx)
2386 if err != nil {
2387 return err
2388 }
2389 if err := tx.Commit(); err != nil {
2390 return err
2391 }
2392 if spaceID != "" {
2393 return db.deleteThreadBlobs(spaceID)
2394 }
2395 return nil
2396}
2397
2398// grantInstanceKey wraps the instance key to a member who doesn't yet hold it.
2399// DO NOTHING (not overwrite): any member may call this for any user id, so
2400// overwriting would let one member replace another's good wrapped key with junk
2401// and brick their avatar decryption. Grants only ever fill a missing holder;
2402// there is no re-key path through here.
2403func (db *DB) grantInstanceKey(userID, wrapped string) error {
2404 _, err := db.sql.Exec(
2405 "INSERT INTO instance_keys(user_id, wrapped) VALUES(?, ?) ON CONFLICT(user_id) DO NOTHING",
2406 userID, wrapped,
2407 )
2408 return err
2409}
2410
2411// Tier is one admin-curated member set with key material (see the schema
2412// comment). Members/Mine/Wrapped/Have are filled per requesting user by
2413// listTiers, so one fetch renders the whole management + key-grant picture.
2414type Tier struct {
2415 ID string `json:"id"`
2416 Name string `json:"name"`
2417 CreatedAt int64 `json:"created_at"`
2418 Members []string `json:"members,omitempty"`
2419 // Mine: the requesting user belongs to this tier. Wrapped: their copy of
2420 // the tier key (unwrap client-side). Have: who already holds a wrapped
2421 // copy, so a holder only wraps to those missing (the grantInstanceKeyTo
2422 // rule, per tier).
2423 Mine bool `json:"mine,omitempty"`
2424 Wrapped string `json:"wrapped,omitempty"`
2425 Have []string `json:"have,omitempty"`
2426 // KeyLost: the key was minted but every holder is gone — unrecoverable
2427 // (grants are refused with 410); the level should be deleted and remade.
2428 KeyLost bool `json:"key_lost,omitempty"`
2429 // Visibility policy: 'shown' | 'hidden' | 'user' (see the schema comment).
2430 Visibility string `json:"visibility,omitempty"`
2431 // Hidden: MY membership of this level is currently hidden from non-members
2432 // — meaningful only when I'm a member and Visibility=='user'.
2433 Hidden bool `json:"hidden,omitempty"`
2434 // Paid access (layer 3): when PriceCents>0 the level is sellable — members
2435 // can subscribe. Plaintext commerce config, like branding.
2436 PriceCents int `json:"price_cents,omitempty"`
2437 Currency string `json:"currency,omitempty"`
2438 Interval string `json:"interval,omitempty"` // 'month' | 'year' | 'once' (lifetime)
2439 ProviderRef string `json:"-"` // provider price id; never sent to clients
2440 // The requesting user's subscription on this level (source='stripe'),
2441 // filled by listTiers for the member's own view: status + when access
2442 // runs out.
2443 Status string `json:"status,omitempty"`
2444 PaidUntil int64 `json:"paid_until,omitempty"`
2445 GraceDeadline int64 `json:"grace_deadline,omitempty"`
2446 // Lifetime: MY membership here is a one-time purchase (never renews,
2447 // nothing to cancel) — the UI drops every billing control off it.
2448 Lifetime bool `json:"lifetime,omitempty"`
2449}
2450
2451func (db *DB) createTier(name string) (string, error) {
2452 id, err := randToken()
2453 if err != nil {
2454 return "", err
2455 }
2456 _, err = db.sql.Exec("INSERT INTO tiers(id, name, created_at) VALUES(?, ?, ?)",
2457 id, name, time.Now().Unix())
2458 return id, err
2459}
2460
2461// deleteTier removes the tier, its memberships and its key rows. Callers must
2462// refuse deletion while any thread is gated on this level (tierGatesThreads):
2463// a gated group's open_key is wrapped under this key, and for self-serve
2464// joiners that wrap is their ONLY key source — deleting it would seal the
2465// group's name and history on their devices for good.
2466func (db *DB) deleteTier(id string) error {
2467 tx, err := db.sql.Begin()
2468 if err != nil {
2469 return err
2470 }
2471 defer tx.Rollback()
2472 for _, q := range []string{
2473 "DELETE FROM tier_keys WHERE tier_id = ?",
2474 "DELETE FROM user_tiers WHERE tier_id = ?",
2475 "DELETE FROM tiers WHERE id = ?",
2476 } {
2477 if _, err := tx.Exec(q, id); err != nil {
2478 return err
2479 }
2480 }
2481 return tx.Commit()
2482}
2483
2484func (db *DB) tierExists(id string) bool {
2485 var x int
2486 return db.sql.QueryRow("SELECT 1 FROM tiers WHERE id = ?", id).Scan(&x) == nil
2487}
2488
2489func (db *DB) tierMember(tierID, userID string) bool {
2490 var x int
2491 return db.sql.QueryRow("SELECT 1 FROM user_tiers WHERE tier_id = ? AND user_id = ?", tierID, userID).Scan(&x) == nil
2492}
2493
2494// tierHolder reports whether a member holds a wrapped copy of the tier key —
2495// the gate for granting to others once the key exists (see postTierKeys).
2496func (db *DB) tierHolder(tierID, userID string) bool {
2497 var x int
2498 return db.sql.QueryRow("SELECT 1 FROM tier_keys WHERE tier_id = ? AND user_id = ?", tierID, userID).Scan(&x) == nil
2499}
2500
2501func (db *DB) tierKeyCount(tierID string) int {
2502 var n int
2503 db.sql.QueryRow("SELECT COUNT(*) FROM tier_keys WHERE tier_id = ?", tierID).Scan(&n)
2504 return n
2505}
2506
2507func (db *DB) assignTier(tierID, userID, source string) error {
2508 _, err := db.sql.Exec(
2509 "INSERT OR IGNORE INTO user_tiers(tier_id, user_id, source, created_at) VALUES(?, ?, ?, ?)",
2510 tierID, userID, source, time.Now().Unix())
2511 return err
2512}
2513
2514// unassignTier drops a member from a tier: membership + their key row. Like
2515// removeThreadMember, this is service-level revocation — the tier key isn't
2516// rotated, so it stops the server serving them tier-gated content, but a
2517// device that already holds the key keeps what it has (rekey is deferred;
2518// docs/community-and-tiers.md § revocation).
2519func (db *DB) unassignTier(tierID, userID string) error {
2520 tx, err := db.sql.Begin()
2521 if err != nil {
2522 return err
2523 }
2524 defer tx.Rollback()
2525 if _, err := tx.Exec("DELETE FROM user_tiers WHERE tier_id = ? AND user_id = ?", tierID, userID); err != nil {
2526 return err
2527 }
2528 if _, err := tx.Exec("DELETE FROM tier_keys WHERE tier_id = ? AND user_id = ?", tierID, userID); err != nil {
2529 return err
2530 }
2531 return tx.Commit()
2532}
2533
2534// setTierPrice makes a level sellable (or, with cents=0, not). Plaintext
2535// commerce config; provider_ref is the payment provider's price id.
2536func (db *DB) setTierPrice(tierID string, cents int, currency, interval, providerRef string) error {
2537 _, err := db.sql.Exec(
2538 "UPDATE tiers SET price_cents = ?, currency = ?, interval = ?, provider_ref = ? WHERE id = ?",
2539 cents, currency, interval, nullable(providerRef), tierID)
2540 return err
2541}
2542
2543// tierPrice returns a level's price config (for building a checkout).
2544func (db *DB) tierPrice(tierID string) (cents int, currency, interval, providerRef string, ok bool) {
2545 var pr sql.NullString
2546 err := db.sql.QueryRow(
2547 "SELECT COALESCE(price_cents,0), COALESCE(currency,''), COALESCE(interval,''), provider_ref FROM tiers WHERE id = ?",
2548 tierID).Scan(&cents, &currency, &interval, &pr)
2549 if err != nil {
2550 return 0, "", "", "", false
2551 }
2552 return cents, currency, interval, pr.String, cents > 0
2553}
2554
2555// Entitlement is a member's paid membership of a level (source='stripe').
2556type Entitlement struct {
2557 TierID string
2558 UserID string
2559 Status string // active | past_due | canceled
2560 PaidUntil int64
2561 GraceDeadline int64
2562 ExtSub string
2563 ExtCustomer string
2564 EventTS int64 // provider event `created` last folded, for ordering
2565 Source string // how the membership was assigned: admin | invite | stripe
2566 Lifetime bool // a one-time purchase: no lifecycle, never swept
2567}
2568
2569// markPaymentRevoked tombstones a refunded/disputed lifetime purchase; a
2570// redelivered grant for it is dead on arrival (applyWebhookEvent).
2571func (db *DB) markPaymentRevoked(paymentIntent string) error {
2572 _, err := db.sql.Exec(
2573 "INSERT INTO revoked_payments(payment_intent, revoked_at) VALUES(?, ?) ON CONFLICT DO NOTHING",
2574 paymentIntent, time.Now().Unix())
2575 return err
2576}
2577
2578func (db *DB) isPaymentRevoked(paymentIntent string) bool {
2579 var n int
2580 _ = db.sql.QueryRow("SELECT COUNT(*) FROM revoked_payments WHERE payment_intent = ?", paymentIntent).Scan(&n)
2581 return n > 0
2582}
2583
2584// setEntitlement upserts a member's paid membership. It ensures the user_tiers
2585// row exists, then stamps the subscription lifecycle — so a webhook can
2586// create-or-update in one call. It NEVER changes an existing row's source: a
2587// paid subscription tracks its lifecycle in the ext_* columns, but an
2588// admin/invite grant underneath stays sticky (so a lapse can't strip an
2589// admin's permanent comp — the revoke path checks source). Assigning the level
2590// to the member (key delivery) is the caller's job on activation, via
2591// assignTier + a tier-key prod, exactly like an admin assignment.
2592func (db *DB) setEntitlement(e Entitlement) error {
2593 tx, err := db.sql.Begin()
2594 if err != nil {
2595 return err
2596 }
2597 defer tx.Rollback()
2598 if _, err := tx.Exec(
2599 "INSERT INTO user_tiers(tier_id, user_id, source, created_at) VALUES(?, ?, 'stripe', ?) ON CONFLICT(tier_id, user_id) DO NOTHING",
2600 e.TierID, e.UserID, time.Now().Unix()); err != nil {
2601 return err
2602 }
2603 // No source= here: preserve an existing admin/invite grant (see doc above).
2604 // The ordering watermark's domain is ONE subscription: within the same sub,
2605 // MAX() so a checkout receipt (EventTS 0 — see applySubEvent) folded after a
2606 // real lifecycle event can't wind it backwards; when the row's subscription
2607 // CHANGES (a level switch moved a different sub onto this row), the old
2608 // sub's watermark is meaningless — keep the incoming event's stamp, or the
2609 // moved sub's later, lower-stamped events would be dropped as stale.
2610 if _, err := tx.Exec(
2611 `UPDATE user_tiers SET status=?, paid_until=?, grace_deadline=?, lifetime=?,
2612 ext_event_ts = CASE WHEN COALESCE(ext_sub,'') = COALESCE(?,'') THEN MAX(ext_event_ts, ?) ELSE ? END,
2613 ext_sub=?, ext_customer=?
2614 WHERE tier_id=? AND user_id=?`,
2615 e.Status, e.PaidUntil, e.GraceDeadline, e.Lifetime,
2616 nullable(e.ExtSub), e.EventTS, e.EventTS,
2617 nullable(e.ExtSub), nullable(e.ExtCustomer), e.TierID, e.UserID); err != nil {
2618 return err
2619 }
2620 return tx.Commit()
2621}
2622
2623// clearEntitlement drops only the subscription tracking (status + provider ids)
2624// from a membership, KEEPING the row, its source, and the tier key. Used when a
2625// stripe subscription lapses on a membership whose sticky source is admin/invite
2626// — the paid lifecycle ends, but the admin-granted access must not.
2627func (db *DB) clearEntitlement(tierID, userID string) error {
2628 _, err := db.sql.Exec(
2629 "UPDATE user_tiers SET status='', paid_until=0, grace_deadline=0, ext_sub=NULL, ext_customer=NULL, ext_event_ts=0 WHERE tier_id=? AND user_id=?",
2630 tierID, userID)
2631 return err
2632}
2633
2634// revokeEntitlement ends a lapsed/cancelled subscription. If the membership
2635// exists ONLY because of the subscription (source='stripe'), it fully unassigns
2636// the tier (key + membership); if an admin/invite grant sits underneath, it
2637// keeps that access and just clears the subscription tracking. removed reports
2638// whether access was actually taken away.
2639func (db *DB) revokeEntitlement(tierID, userID string) (removed bool, err error) {
2640 src, _ := db.tierSource(tierID, userID)
2641 if src == "stripe" {
2642 return true, db.unassignTier(tierID, userID)
2643 }
2644 return false, db.clearEntitlement(tierID, userID)
2645}
2646
2647// tierSource reads how a membership was assigned (admin | invite | stripe).
2648func (db *DB) tierSource(tierID, userID string) (string, bool) {
2649 var src sql.NullString
2650 err := db.sql.QueryRow(
2651 "SELECT source FROM user_tiers WHERE tier_id=? AND user_id=?", tierID, userID).Scan(&src)
2652 if err != nil {
2653 return "", false
2654 }
2655 return src.String, true
2656}
2657
2658// entitlementBySub finds the paid membership a provider subscription id maps to
2659// — how a webhook (which knows only the subscription) locates the member.
2660func (db *DB) entitlementBySub(extSub string) (Entitlement, bool) {
2661 var e Entitlement
2662 var status sql.NullString
2663 err := db.sql.QueryRow(
2664 "SELECT tier_id, user_id, COALESCE(status,''), COALESCE(paid_until,0), COALESCE(grace_deadline,0), COALESCE(ext_event_ts,0), COALESCE(lifetime,0) FROM user_tiers WHERE ext_sub = ?",
2665 extSub).Scan(&e.TierID, &e.UserID, &status, &e.PaidUntil, &e.GraceDeadline, &e.EventTS, &e.Lifetime)
2666 if err != nil {
2667 return Entitlement{}, false
2668 }
2669 e.ExtSub = extSub
2670 e.Status = status.String
2671 return e, true
2672}
2673
2674// memberEntitlement returns a member's subscription on one level (for the
2675// subscription-management UI). Identified by holding a provider subscription id
2676// (any source — an admin-granted member can still self-subscribe), or ok=false.
2677func (db *DB) memberEntitlement(tierID, userID string) (Entitlement, bool) {
2678 var e Entitlement
2679 var status, sub, cust sql.NullString
2680 err := db.sql.QueryRow(
2681 "SELECT COALESCE(status,''), COALESCE(paid_until,0), COALESCE(grace_deadline,0), ext_sub, ext_customer, COALESCE(ext_event_ts,0), COALESCE(lifetime,0) FROM user_tiers WHERE tier_id=? AND user_id=? AND ext_sub IS NOT NULL AND ext_sub != ''",
2682 tierID, userID).Scan(&status, &e.PaidUntil, &e.GraceDeadline, &sub, &cust, &e.EventTS, &e.Lifetime)
2683 if err != nil {
2684 return Entitlement{}, false
2685 }
2686 e.TierID, e.UserID = tierID, userID
2687 e.Status, e.ExtSub, e.ExtCustomer = status.String, sub.String, cust.String
2688 return e, true
2689}
2690
2691// entitlementsDueForRevoke lists subscriptions to revoke: those whose explicit
2692// grace deadline has elapsed, AND — the true backstop — active/past-due subs
2693// whose paid period ran out more than the grace window ago with no renewal
2694// webhook ever arriving (endpoint down, instance hibernated). source rides along
2695// so the sweep can tell a pure-stripe membership (fully unassign) from one with
2696// an admin/invite grant underneath (keep access, just drop the sub).
2697func (db *DB) entitlementsDueForRevoke(now int64) ([]Entitlement, error) {
2698 graceSecs := int64(billingGrace.Seconds())
2699 rows, err := db.sql.Query(
2700 `SELECT tier_id, user_id, source FROM user_tiers
2701 WHERE ext_sub IS NOT NULL AND ext_sub != '' AND (
2702 (grace_deadline > 0 AND grace_deadline <= ?)
2703 OR (paid_until > 0 AND paid_until + ? <= ?)
2704 )`,
2705 now, graceSecs, now)
2706 if err != nil {
2707 return nil, err
2708 }
2709 defer rows.Close()
2710 out := []Entitlement{}
2711 for rows.Next() {
2712 var e Entitlement
2713 var src sql.NullString
2714 if err := rows.Scan(&e.TierID, &e.UserID, &src); err != nil {
2715 return nil, err
2716 }
2717 e.Source = src.String
2718 out = append(out, e)
2719 }
2720 return out, rows.Err()
2721}
2722
2723// entitlementsNeedingRepair finds paid memberships stuck ACTIVE with no
2724// period end — rows whose lifecycle events were lost, which neither
2725// entitlementsDueForRevoke clause can ever match. The sweep re-fetches
2726// provider truth for these (repairEntitlements).
2727func (db *DB) entitlementsNeedingRepair(limit int) ([]Entitlement, error) {
2728 rows, err := db.sql.Query(
2729 `SELECT tier_id, user_id, ext_sub FROM user_tiers
2730 WHERE ext_sub IS NOT NULL AND ext_sub != '' AND status = 'active' AND paid_until = 0
2731 AND COALESCE(lifetime, 0) = 0
2732 LIMIT ?`, limit)
2733 if err != nil {
2734 return nil, err
2735 }
2736 defer rows.Close()
2737 out := []Entitlement{}
2738 for rows.Next() {
2739 var e Entitlement
2740 if err := rows.Scan(&e.TierID, &e.UserID, &e.ExtSub); err != nil {
2741 return nil, err
2742 }
2743 out = append(out, e)
2744 }
2745 return out, rows.Err()
2746}
2747
2748// grantTierKey stores one member's wrapped copy of a tier key. DO NOTHING,
2749// never overwrite — the grantInstanceKey anti-brick rule, per tier.
2750func (db *DB) grantTierKey(tierID, userID, wrapped string) error {
2751 _, err := db.sql.Exec(
2752 "INSERT INTO tier_keys(tier_id, user_id, wrapped) VALUES(?, ?, ?) ON CONFLICT(tier_id, user_id) DO NOTHING",
2753 tierID, userID, wrapped)
2754 return err
2755}
2756
2757var errAlreadyMinted = errors.New("tier key already minted")
2758
2759// mintTierKeys is the ONE-TIME key creation for a level: it flips key_minted
2760// and stores the initial grants in a single transaction. The guarded UPDATE
2761// makes concurrent minters serialize — exactly one wins; the loser's key
2762// never enters the tier (it reloads and proceeds as a nudger/holder instead).
2763func (db *DB) mintTierKeys(tierID string, grants []ThreadMemberKey) error {
2764 tx, err := db.sql.Begin()
2765 if err != nil {
2766 return err
2767 }
2768 defer tx.Rollback()
2769 res, err := tx.Exec("UPDATE tiers SET key_minted = 1 WHERE id = ? AND key_minted = 0", tierID)
2770 if err != nil {
2771 return err
2772 }
2773 if n, _ := res.RowsAffected(); n == 0 {
2774 return errAlreadyMinted
2775 }
2776 for _, g := range grants {
2777 if _, err := tx.Exec(
2778 "INSERT INTO tier_keys(tier_id, user_id, wrapped) VALUES(?, ?, ?) ON CONFLICT(tier_id, user_id) DO NOTHING",
2779 tierID, g.UserID, g.Wrapped); err != nil {
2780 return err
2781 }
2782 }
2783 return tx.Commit()
2784}
2785
2786func (db *DB) tierKeyMinted(tierID string) bool {
2787 var x int
2788 if db.sql.QueryRow("SELECT COALESCE(key_minted, 0) FROM tiers WHERE id = ?", tierID).Scan(&x) != nil {
2789 return false
2790 }
2791 return x == 1
2792}
2793
2794// setInviteTier attaches an access level to an invite (a cohort invite): the
2795// claimer is assigned the level on first-device claim (claimKeyInvite).
2796func (db *DB) setInviteTier(inviteID, tierID string) error {
2797 _, err := db.sql.Exec("UPDATE key_invites SET tier_id = ? WHERE id = ?", tierID, inviteID)
2798 return err
2799}
2800
2801// tierName returns a level's plaintext name ("" if unknown). Names are
2802// branding-class by design — they render on claim screens where no key exists.
2803func (db *DB) tierName(id string) string {
2804 var n string
2805 if db.sql.QueryRow("SELECT name FROM tiers WHERE id = ?", id).Scan(&n) != nil {
2806 return ""
2807 }
2808 return n
2809}
2810
2811// userTiersNeedingKeys lists the levels a member belongs to but holds no key
2812// copy for — the setPubkey prod targets (a cohort joiner becomes wrappable-to
2813// the moment their pubkey lands).
2814func (db *DB) userTiersNeedingKeys(userID string) []string {
2815 rows, err := db.sql.Query(`
2816 SELECT ut.tier_id FROM user_tiers ut
2817 LEFT JOIN tier_keys k ON k.tier_id = ut.tier_id AND k.user_id = ut.user_id
2818 WHERE ut.user_id = ? AND k.user_id IS NULL`, userID)
2819 if err != nil {
2820 return nil
2821 }
2822 defer rows.Close()
2823 out := []string{}
2824 for rows.Next() {
2825 var id string
2826 if rows.Scan(&id) == nil {
2827 out = append(out, id)
2828 }
2829 }
2830 return out
2831}
2832
2833// tierGatesThreads counts threads (any visibility) whose gate is this level —
2834// the deletion guard: those groups' open_keys are wrapped under this level's
2835// key forever, so deleting the level would seal them for self-serve joiners.
2836func (db *DB) tierGatesThreads(tierID string) int {
2837 var n int
2838 db.sql.QueryRow("SELECT COUNT(*) FROM threads WHERE open_tier = ?", tierID).Scan(&n)
2839 return n
2840}
2841
2842// tierMemberIDs / tierHolderIDs feed the targeted WS prods: a key nudge goes
2843// only to members who can actually help (holders), a grant prod only to the
2844// members who just received a copy.
2845func (db *DB) tierMemberIDs(tierID string) ([]string, error) {
2846 return db.idColumn("SELECT user_id FROM user_tiers WHERE tier_id = ?", tierID)
2847}
2848
2849func (db *DB) tierHolderIDs(tierID string) ([]string, error) {
2850 return db.idColumn("SELECT user_id FROM tier_keys WHERE tier_id = ?", tierID)
2851}
2852
2853func (db *DB) idColumn(query, arg string) ([]string, error) {
2854 rows, err := db.sql.Query(query, arg)
2855 if err != nil {
2856 return nil, err
2857 }
2858 defer rows.Close()
2859 out := []string{}
2860 for rows.Next() {
2861 var id string
2862 if err := rows.Scan(&id); err != nil {
2863 return nil, err
2864 }
2865 out = append(out, id)
2866 }
2867 return out, rows.Err()
2868}
2869
2870// listTiers returns every tier (names are plaintext space policy, visible to
2871// all members like the roster), personalised with the caller's membership,
2872// their wrapped key copy, and who already holds one.
2873// listTiers returns every level, personalised to userID. isAdmin callers (and
2874// the level's own members) get the FULL member/holder lists — an admin manages
2875// them, and a member must be able to wrap the key to every co-member. To an
2876// OUTSIDER (not a member, not admin) each level's members are filtered by its
2877// visibility policy: 'shown' → all, 'hidden' → none, 'user' → only members who
2878// opted visible. So a hidden level never leaks its roster to non-members, while
2879// the crypto's own truth — members know each other — is preserved.
2880func (db *DB) listTiers(userID string, isAdmin bool) ([]Tier, error) {
2881 rows, err := db.sql.Query(`
2882 SELECT t.id, t.name, t.created_at, COALESCE(k.wrapped, ''), COALESCE(t.key_minted, 0),
2883 COALESCE(t.visibility, 'shown'),
2884 CASE WHEN m.user_id IS NULL THEN 0 ELSE 1 END,
2885 COALESCE(m.visible, 1),
2886 COALESCE(t.price_cents, 0), COALESCE(t.currency, ''), COALESCE(t.interval, ''),
2887 COALESCE(m.status, ''), COALESCE(m.paid_until, 0), COALESCE(m.grace_deadline, 0),
2888 COALESCE(m.lifetime, 0)
2889 FROM tiers t
2890 LEFT JOIN tier_keys k ON k.tier_id = t.id AND k.user_id = ?
2891 LEFT JOIN user_tiers m ON m.tier_id = t.id AND m.user_id = ?
2892 ORDER BY t.created_at, t.id`, userID, userID)
2893 if err != nil {
2894 return nil, err
2895 }
2896 defer rows.Close()
2897 out := []Tier{}
2898 minted := []bool{}
2899 myVisible := []bool{}
2900 for rows.Next() {
2901 var t Tier
2902 var m, mine, vis int
2903 if err := rows.Scan(&t.ID, &t.Name, &t.CreatedAt, &t.Wrapped, &m, &t.Visibility, &mine, &vis,
2904 &t.PriceCents, &t.Currency, &t.Interval, &t.Status, &t.PaidUntil, &t.GraceDeadline, &t.Lifetime); err != nil {
2905 return nil, err
2906 }
2907 t.Mine = mine == 1
2908 minted = append(minted, m == 1)
2909 myVisible = append(myVisible, vis == 1)
2910 out = append(out, t)
2911 }
2912 if err := rows.Err(); err != nil {
2913 return nil, err
2914 }
2915 for i := range out {
2916 insider := isAdmin || out[i].Mine
2917 if insider {
2918 if out[i].Members, err = db.tierMemberIDs(out[i].ID); err != nil {
2919 return nil, err
2920 }
2921 if out[i].Have, err = db.tierHolderIDs(out[i].ID); err != nil {
2922 return nil, err
2923 }
2924 out[i].KeyLost = minted[i] && len(out[i].Have) == 0
2925 } else if out[i].Members, err = db.publicTierMemberIDs(out[i].ID, out[i].Visibility); err != nil {
2926 return nil, err
2927 }
2928 // My own show/hide state — only a control when I'm a member of a
2929 // 'user'-policy level; otherwise the policy decides, no personal say.
2930 out[i].Hidden = out[i].Mine && out[i].Visibility == "user" && !myVisible[i]
2931 }
2932 return out, nil
2933}
2934
2935// publicTierMemberIDs is the member list an OUTSIDER may see: all for 'shown',
2936// none for 'hidden', the opted-in subset for 'user'.
2937func (db *DB) publicTierMemberIDs(tierID, visibility string) ([]string, error) {
2938 switch visibility {
2939 case "hidden":
2940 return []string{}, nil
2941 case "user":
2942 return db.idColumn("SELECT user_id FROM user_tiers WHERE tier_id = ? AND COALESCE(visible, 1) = 1", tierID)
2943 default: // shown
2944 return db.tierMemberIDs(tierID)
2945 }
2946}
2947
2948// setTierVisibility sets a level's admin policy ('shown'|'hidden'|'user').
2949func (db *DB) setTierVisibility(tierID, visibility string) error {
2950 _, err := db.sql.Exec("UPDATE tiers SET visibility = ? WHERE id = ?", visibility, tierID)
2951 return err
2952}
2953
2954// setUserTierVisible sets one member's show/hide for a level (their own choice,
2955// honoured only while the level's policy is 'user').
2956func (db *DB) setUserTierVisible(tierID, userID string, visible bool) error {
2957 v := 0
2958 if visible {
2959 v = 1
2960 }
2961 _, err := db.sql.Exec("UPDATE user_tiers SET visible = ? WHERE tier_id = ? AND user_id = ?", v, tierID, userID)
2962 return err
2963}
2964
2965type Thread struct {
2966 ID string `json:"id"`
2967 Kind string `json:"kind"` // self | dm | group
2968 NameBlob string `json:"name_blob,omitempty"`
2969 CreatedAt int64 `json:"created_at"`
2970 // Wrapped is this thread's key, wrapped to the requesting user (set only by
2971 // threadsForUser, so the client can unwrap and decrypt in one round trip).
2972 Wrapped string `json:"wrapped,omitempty"`
2973 // Members lists the thread's user ids (so the client can label a DM by the
2974 // other person and render group membership).
2975 Members []string `json:"members,omitempty"`
2976 // LastBlob/LastAt are the most recent message's ciphertext + time, so the
2977 // chat list can show a (client-decrypted) preview without opening the thread.
2978 LastBlob string `json:"last_blob,omitempty"`
2979 LastAt int64 `json:"last_at,omitempty"`
2980 // Avatar is the group photo's blob id (encrypted with the thread key).
2981 Avatar string `json:"avatar,omitempty"`
2982 // Hidden is true when the requesting user has closed (hidden) this chat.
2983 Hidden bool `json:"hidden,omitempty"`
2984 // LastReadAt is the requesting user's read watermark — the time of the
2985 // newest message they've read here, on any of their devices (0 = never).
2986 // The client compares it to LastAt to decide the unread dot.
2987 LastReadAt int64 `json:"last_read_at,omitempty"`
2988 // Pinned is true when the requesting user keeps this chat at the top of
2989 // their list.
2990 Pinned bool `json:"pinned,omitempty"`
2991 // Muted is true when the requesting user has muted this chat — no pushes,
2992 // no unread-badge escalation on their devices.
2993 Muted bool `json:"muted,omitempty"`
2994 // DMClosed is true when either party ended this DM (issue #578): the
2995 // server refuses new messages until someone reopens it, and clients show
2996 // the closed banner instead of the composer.
2997 DMClosed bool `json:"dm_closed,omitempty"`
2998 // Visibility is "open" for a community group any member can discover and
2999 // join (see openThreads); omitted for ordinary invite-only threads.
3000 Visibility string `json:"visibility,omitempty"`
3001 // OpenKey is the thread key encrypted under the instance key — or, when
3002 // OpenTier is set, under that tier's key (open groups only). It's the key
3003 // source for members who joined self-serve with no per-member wrapped copy.
3004 OpenKey string `json:"open_key,omitempty"`
3005 // OpenTier gates an open group to one access level ("" = every member).
3006 OpenTier string `json:"open_tier,omitempty"`
3007 // DefaultJoin marks an open group new members are auto-joined to.
3008 DefaultJoin bool `json:"default_join,omitempty"`
3009}
3010
3011// ThreadMemberKey pairs a member with the thread key wrapped to their pubkey.
3012type ThreadMemberKey struct {
3013 UserID string `json:"user_id"`
3014 Wrapped string `json:"wrapped"`
3015}
3016
3017// createThread creates a thread with its members and their wrapped key blobs in
3018// one transaction. nameBlob is the client-encrypted group name ("" for dm/self).
3019func (db *DB) createThread(kind, nameBlob string, members []ThreadMemberKey) (string, error) {
3020 return db.createThreadVis(kind, nameBlob, members, "", "", "")
3021}
3022
3023// createThreadVis also stamps visibility + open_key for open community groups
3024// (see openThreads). "" visibility means the default 'invite'. Members with an
3025// empty Wrapped get no thread_keys row — an open group's key reaches them via
3026// open_key instead.
3027func (db *DB) createThreadVis(kind, nameBlob string, members []ThreadMemberKey, visibility, openKey, openTier string) (string, error) {
3028 id, err := randToken()
3029 if err != nil {
3030 return "", err
3031 }
3032 tx, err := db.sql.Begin()
3033 if err != nil {
3034 return "", err
3035 }
3036 defer tx.Rollback()
3037 now := time.Now().Unix()
3038 var nb any
3039 if nameBlob != "" {
3040 nb = nameBlob
3041 }
3042 if visibility == "" {
3043 visibility = "invite"
3044 }
3045 var ok, ot any
3046 if openKey != "" {
3047 ok = openKey
3048 }
3049 if openTier != "" {
3050 ot = openTier
3051 }
3052 if _, err := tx.Exec("INSERT INTO threads(id, kind, name_blob, created_at, visibility, open_key, open_tier) VALUES(?, ?, ?, ?, ?, ?, ?)", id, kind, nb, now, visibility, ok, ot); err != nil {
3053 return "", err
3054 }
3055 for _, m := range members {
3056 if _, err := tx.Exec("INSERT INTO thread_members(thread_id, user_id, created_at) VALUES(?, ?, ?)", id, m.UserID, now); err != nil {
3057 return "", err
3058 }
3059 if m.Wrapped == "" {
3060 continue
3061 }
3062 if _, err := tx.Exec("INSERT INTO thread_keys(thread_id, user_id, wrapped) VALUES(?, ?, ?)", id, m.UserID, m.Wrapped); err != nil {
3063 return "", err
3064 }
3065 }
3066 return id, tx.Commit()
3067}
3068
3069// addThreadMember adds one member of an existing thread. The thread_keys insert
3070// is DO NOTHING, never REPLACE: any member may add another, so replacing would
3071// let one member overwrite an existing member's good wrapped key with junk and
3072// lock them out of the thread. Legitimate re-keying (invite adoption) is a
3073// separate, explicit path (see adoptUserInvite).
3074func (db *DB) addThreadMember(threadID, userID, wrapped string) error {
3075 tx, err := db.sql.Begin()
3076 if err != nil {
3077 return err
3078 }
3079 defer tx.Rollback()
3080 now := time.Now().Unix()
3081 if _, err := tx.Exec("INSERT OR IGNORE INTO thread_members(thread_id, user_id, created_at) VALUES(?, ?, ?)", threadID, userID, now); err != nil {
3082 return err
3083 }
3084 if _, err := tx.Exec("INSERT INTO thread_keys(thread_id, user_id, wrapped) VALUES(?, ?, ?) ON CONFLICT(thread_id, user_id) DO NOTHING", threadID, userID, wrapped); err != nil {
3085 return err
3086 }
3087 return tx.Commit()
3088}
3089
3090func (db *DB) threadMember(threadID, userID string) bool {
3091 var x int
3092 if db.sql.QueryRow("SELECT 1 FROM thread_members WHERE thread_id = ? AND user_id = ?", threadID, userID).Scan(&x) == nil {
3093 return true
3094 }
3095 // The space thread (spacethread.go) has IMPLICIT membership: every member
3096 // of the space is a member by construction, so there are no rows to keep in
3097 // sync and no join path that can drift. Agents are excluded (mirroring
3098 // joinDefaultThreads) — a bot reading the space log must be a deliberate,
3099 // disclosed choice, made by adding an explicit membership row above.
3100 return db.threadKind(threadID) == "space" && db.userExists(userID) && !db.isAgent(userID)
3101}
3102
3103// messageInThread reports whether message id lives in threadID. Binds a
3104// reaction/edit to the thread the client names (membership is checked
3105// separately), so a member of one thread can't react to or edit messages in a
3106// thread they don't belong to.
3107func (db *DB) messageInThread(id int64, threadID string) bool {
3108 var x int
3109 return db.sql.QueryRow("SELECT 1 FROM messages WHERE id = ? AND thread_id = ?", id, threadID).Scan(&x) == nil
3110}
3111
3112// threadKind returns a thread's kind ("self"/"dm"/"group"), or "" if unknown.
3113func (db *DB) threadKind(threadID string) string {
3114 var k string
3115 if db.sql.QueryRow("SELECT kind FROM threads WHERE id = ?", threadID).Scan(&k) != nil {
3116 return ""
3117 }
3118 return k
3119}
3120
3121// deleteThread removes a thread and its membership/key rows. Used to roll back a
3122// DM thread that lost the dedupe race (see postThread).
3123func (db *DB) deleteThread(id string) error {
3124 tx, err := db.sql.Begin()
3125 if err != nil {
3126 return err
3127 }
3128 defer tx.Rollback()
3129 for _, q := range []string{
3130 "DELETE FROM thread_keys WHERE thread_id = ?",
3131 "DELETE FROM thread_members WHERE thread_id = ?",
3132 "DELETE FROM closed_dms WHERE thread_id = ?",
3133 "DELETE FROM threads WHERE id = ?",
3134 } {
3135 if _, err := tx.Exec(q, id); err != nil {
3136 return err
3137 }
3138 }
3139 if err := tx.Commit(); err != nil {
3140 return err
3141 }
3142 // The thread's messages referenced these blobs; drop their bytes too.
3143 return db.deleteThreadBlobs(id)
3144}
3145
3146// wrappedKeyFor returns a member's wrapped thread key (to unwrap client-side).
3147func (db *DB) wrappedKeyFor(threadID, userID string) (string, bool, error) {
3148 var w string
3149 err := db.sql.QueryRow("SELECT wrapped FROM thread_keys WHERE thread_id = ? AND user_id = ?", threadID, userID).Scan(&w)
3150 if err == sql.ErrNoRows {
3151 return "", false, nil
3152 }
3153 if err != nil {
3154 return "", false, err
3155 }
3156 return w, true, nil
3157}
3158
3159// threadsForUser lists the threads a user belongs to (their chat list), each
3160// with the thread key wrapped to that user and the member id list.
3161func (db *DB) threadsForUser(userID string) ([]Thread, error) {
3162 rows, err := db.sql.Query(`
3163 SELECT t.id, t.kind, COALESCE(t.name_blob, ''), t.created_at, COALESCE(k.wrapped, ''),
3164 -- tombstone rows (#581) are sync bookkeeping, not activity: they must
3165 -- not become the preview blob or bump the thread's recency below.
3166 COALESCE((SELECT blob FROM messages WHERE thread_id = t.id AND tombstone_of IS NULL ORDER BY id DESC LIMIT 1), ''),
3167 COALESCE((SELECT created_at FROM messages WHERE thread_id = t.id AND tombstone_of IS NULL ORDER BY id DESC LIMIT 1), 0),
3168 COALESCE(t.avatar_blob, ''),
3169 COALESCE(t.visibility, 'invite'), COALESCE(t.open_key, ''), COALESCE(t.open_tier, ''), COALESCE(t.default_join, 0),
3170 EXISTS(SELECT 1 FROM hidden_threads h WHERE h.thread_id = t.id AND h.user_id = m.user_id),
3171 EXISTS(SELECT 1 FROM pinned_threads p WHERE p.thread_id = t.id AND p.user_id = m.user_id) AS pinned,
3172 EXISTS(SELECT 1 FROM muted_threads mu WHERE mu.thread_id = t.id AND mu.user_id = m.user_id),
3173 EXISTS(SELECT 1 FROM closed_dms c WHERE c.thread_id = t.id),
3174 COALESCE((SELECT last_read_at FROM read_marks r WHERE r.thread_id = t.id AND r.user_id = m.user_id), 0)
3175 FROM threads t
3176 JOIN thread_members m ON m.thread_id = t.id
3177 LEFT JOIN thread_keys k ON k.thread_id = t.id AND k.user_id = m.user_id
3178 WHERE m.user_id = ?
3179 ORDER BY pinned DESC,
3180 COALESCE((SELECT MAX(created_at) FROM messages WHERE thread_id = t.id AND tombstone_of IS NULL), t.created_at) DESC,
3181 -- created_at is second-resolution, so threads active in the same
3182 -- second tie; message ids are monotonic, making the order stable.
3183 COALESCE((SELECT MAX(id) FROM messages WHERE thread_id = t.id AND tombstone_of IS NULL), 0) DESC,
3184 t.created_at DESC`, userID)
3185 if err != nil {
3186 return nil, err
3187 }
3188 defer rows.Close()
3189 out := []Thread{}
3190 for rows.Next() {
3191 var t Thread
3192 if err := rows.Scan(&t.ID, &t.Kind, &t.NameBlob, &t.CreatedAt, &t.Wrapped, &t.LastBlob, &t.LastAt, &t.Avatar, &t.Visibility, &t.OpenKey, &t.OpenTier, &t.DefaultJoin, &t.Hidden, &t.Pinned, &t.Muted, &t.DMClosed, &t.LastReadAt); err != nil {
3193 return nil, err
3194 }
3195 if t.Visibility == "invite" {
3196 t.Visibility = "" // the default; keep the JSON row lean
3197 }
3198 out = append(out, t)
3199 }
3200 if err := rows.Err(); err != nil {
3201 return nil, err
3202 }
3203 for i := range out {
3204 if out[i].Members, err = db.memberUserIDs(out[i].ID); err != nil {
3205 return nil, err
3206 }
3207 }
3208 return out, nil
3209}
3210
3211// OpenThread is a community-directory row: enough for any member to render
3212// and self-join an open group. The name/avatar blobs decrypt with the thread
3213// key, which every member can reach via open_key + the instance key — so the
3214// whole directory is readable by any member and by nobody else.
3215type OpenThread struct {
3216 ID string `json:"id"`
3217 NameBlob string `json:"name_blob,omitempty"`
3218 CreatedAt int64 `json:"created_at"`
3219 Avatar string `json:"avatar,omitempty"`
3220 OpenKey string `json:"open_key"`
3221 MemberCount int `json:"member_count"`
3222 Joined bool `json:"joined"`
3223 DefaultJoin bool `json:"default_join,omitempty"`
3224 // Level gate: the tier that may join ("" = everyone), its plaintext name
3225 // (so ineligible members see an honest "For Coaches" lock, not a blank),
3226 // and whether the requesting user is eligible.
3227 OpenTier string `json:"open_tier,omitempty"`
3228 TierName string `json:"tier_name,omitempty"`
3229 Eligible bool `json:"eligible"`
3230}
3231
3232// openThreads lists every open group (joined or not) for the community
3233// directory, busiest first.
3234func (db *DB) openThreads(userID string) ([]OpenThread, error) {
3235 rows, err := db.sql.Query(`
3236 SELECT t.id, COALESCE(t.name_blob, ''), t.created_at, COALESCE(t.avatar_blob, ''), COALESCE(t.open_key, ''),
3237 (SELECT COUNT(*) FROM thread_members m WHERE m.thread_id = t.id) AS n,
3238 EXISTS(SELECT 1 FROM thread_members m WHERE m.thread_id = t.id AND m.user_id = ?),
3239 COALESCE(t.default_join, 0),
3240 COALESCE(t.open_tier, ''), COALESCE(tr.name, ''),
3241 (COALESCE(t.open_tier, '') = '' OR EXISTS(
3242 SELECT 1 FROM user_tiers ut WHERE ut.tier_id = t.open_tier AND ut.user_id = ?))
3243 FROM threads t
3244 LEFT JOIN tiers tr ON tr.id = t.open_tier
3245 WHERE t.visibility = 'open'
3246 ORDER BY n DESC, t.created_at DESC`, userID, userID)
3247 if err != nil {
3248 return nil, err
3249 }
3250 defer rows.Close()
3251 out := []OpenThread{}
3252 for rows.Next() {
3253 var t OpenThread
3254 if err := rows.Scan(&t.ID, &t.NameBlob, &t.CreatedAt, &t.Avatar, &t.OpenKey, &t.MemberCount, &t.Joined, &t.DefaultJoin, &t.OpenTier, &t.TierName, &t.Eligible); err != nil {
3255 return nil, err
3256 }
3257 out = append(out, t)
3258 }
3259 return out, rows.Err()
3260}
3261
3262// threadIsOpen reports whether a thread is an open (discoverable) group.
3263func (db *DB) threadIsOpen(threadID string) bool {
3264 var v string
3265 if db.sql.QueryRow("SELECT COALESCE(visibility, 'invite') FROM threads WHERE id = ?", threadID).Scan(&v) != nil {
3266 return false
3267 }
3268 return v == "open"
3269}
3270
3271// threadOpenInfo reports whether a thread already has an open_key stored and
3272// which access level (if any) gates it — the immutability check for
3273// postThreadVisibility.
3274func (db *DB) threadOpenInfo(threadID string) (hasKey bool, tier string) {
3275 var k sql.NullString
3276 var t sql.NullString
3277 if db.sql.QueryRow("SELECT open_key, open_tier FROM threads WHERE id = ?", threadID).Scan(&k, &t) != nil {
3278 return false, ""
3279 }
3280 return k.Valid && k.String != "", t.String
3281}
3282
3283// nullable maps "" to SQL NULL, for optional TEXT columns.
3284func nullable(s string) any {
3285 if s == "" {
3286 return nil
3287 }
3288 return s
3289}
3290
3291// setThreadDefault flags (or unflags) an open group as joined-by-default for
3292// new members. Policy, not key material — flipping it moves no keys.
3293func (db *DB) setThreadDefault(threadID string, def bool) error {
3294 v := 0
3295 if def {
3296 v = 1
3297 }
3298 _, err := db.sql.Exec("UPDATE threads SET default_join = ? WHERE id = ?", v, threadID)
3299 return err
3300}
3301
3302// joinDefaultThreads adds a brand-new member to every default open group —
3303// membership rows only, like any self-serve join (open_key carries the key).
3304// Closed groups stop defaulting even if their flag is still set. Agents are
3305// skipped: auto-adding a bot would silently widen what its sidecar can read,
3306// and bot membership must stay a deliberate, disclosed choice (docs/bots.md).
3307func (db *DB) joinDefaultThreads(userID string) error {
3308 _, err := db.sql.Exec(`
3309 INSERT OR IGNORE INTO thread_members(thread_id, user_id, created_at)
3310 SELECT t.id, ?, ?
3311 FROM threads t
3312 WHERE t.visibility = 'open' AND COALESCE(t.default_join, 0) = 1
3313 AND COALESCE(t.open_tier, '') = ''
3314 AND (SELECT COALESCE(is_agent, 0) FROM users WHERE id = ?) = 0`,
3315 userID, time.Now().Unix(), userID)
3316 return err
3317}
3318
3319var errNotOpen = errors.New("not an open thread")
3320
3321// joinOpenThread self-serve-joins a member to an open group: a membership row
3322// only, no thread_keys row — the group's key reaches them via open_key, which
3323// they can already unwrap with the instance key. Errors errNotOpen for
3324// anything that isn't an open group (including unknown threads).
3325func (db *DB) joinOpenThread(threadID, userID string) error {
3326 tx, err := db.sql.Begin()
3327 if err != nil {
3328 return err
3329 }
3330 defer tx.Rollback()
3331 var v, gate string
3332 if err := tx.QueryRow("SELECT COALESCE(visibility, 'invite'), COALESCE(open_tier, '') FROM threads WHERE id = ?", threadID).Scan(&v, &gate); err != nil {
3333 if err == sql.ErrNoRows {
3334 return errNotOpen
3335 }
3336 return err
3337 }
3338 if v != "open" {
3339 return errNotOpen
3340 }
3341 // A level-gated group is open only to that access level — the same
3342 // errNotOpen to outsiders, so the refusal leaks nothing they can't
3343 // already see in the directory's honest lock.
3344 if gate != "" {
3345 var x int
3346 if err := tx.QueryRow("SELECT 1 FROM user_tiers WHERE tier_id = ? AND user_id = ?", gate, userID).Scan(&x); err != nil {
3347 if err == sql.ErrNoRows {
3348 return errNotOpen
3349 }
3350 return err
3351 }
3352 }
3353 if _, err := tx.Exec("INSERT OR IGNORE INTO thread_members(thread_id, user_id, created_at) VALUES(?, ?, ?)", threadID, userID, time.Now().Unix()); err != nil {
3354 return err
3355 }
3356 return tx.Commit()
3357}
3358
3359// setThreadVisibility opens or closes a group for discovery. Opening stores
3360// open_key only when none exists yet (COALESCE, the grantInstanceKey
3361// anti-brick rule: any member may call this, so an overwrite could replace a
3362// good key blob with junk and cut off every self-serve joiner). Closing keeps
3363// open_key — members who joined self-serve still decrypt through it; 'invite'
3364// only stops discovery and new joins.
3365func (db *DB) setThreadVisibility(threadID, visibility, openKey, openTier string) error {
3366 if visibility == "open" {
3367 // open_tier is stamped in the same breath as open_key and only then —
3368 // SET expressions read the pre-update row, so both CASEs test the OLD
3369 // open_key. A later gate change would need a rekey the never-overwrite
3370 // rule forbids.
3371 _, err := db.sql.Exec(`UPDATE threads SET visibility = 'open',
3372 open_tier = CASE WHEN open_key IS NULL THEN ? ELSE open_tier END,
3373 open_key = COALESCE(open_key, ?) WHERE id = ?`, nullable(openTier), openKey, threadID)
3374 return err
3375 }
3376 _, err := db.sql.Exec("UPDATE threads SET visibility = ? WHERE id = ?", visibility, threadID)
3377 return err
3378}
3379
3380// spaceThread returns the instance's one space thread (spacethread.go), if it
3381// has been created: its id and open_key (the space key wrapped under the
3382// instance key — the same open-group mechanism, opaque to the server).
3383func (db *DB) spaceThread() (id, openKey string, ok bool, err error) {
3384 var k sql.NullString
3385 err = db.sql.QueryRow("SELECT id, open_key FROM threads WHERE kind = 'space'").Scan(&id, &k)
3386 if err == sql.ErrNoRows {
3387 return "", "", false, nil
3388 }
3389 if err != nil {
3390 return "", "", false, err
3391 }
3392 return id, k.String, true, nil
3393}
3394
3395// createSpaceThread creates THE space thread if none exists, storing openKey.
3396// Idempotent under races: the partial unique index (idx_one_space_thread)
3397// makes a losing concurrent insert fail, and the loser re-reads the winner's
3398// row — so there is exactly one space thread and its open_key, once stored, is
3399// never overwritten (the grantInstanceKey anti-brick rule).
3400func (db *DB) createSpaceThread(openKey string) (id, storedKey string, created bool, err error) {
3401 if id, storedKey, ok, err := db.spaceThread(); err != nil || ok {
3402 return id, storedKey, false, err
3403 }
3404 id, err = randToken()
3405 if err != nil {
3406 return "", "", false, err
3407 }
3408 _, err = db.sql.Exec(
3409 "INSERT INTO threads(id, kind, created_at, visibility, open_key) VALUES(?, 'space', ?, 'invite', ?)",
3410 id, time.Now().Unix(), openKey)
3411 if err != nil {
3412 // Lost the create race: the unique index rejected a second space thread.
3413 // The winner's row is the space thread — return it.
3414 if id2, key2, ok, err2 := db.spaceThread(); err2 == nil && ok {
3415 return id2, key2, false, nil
3416 }
3417 return "", "", false, err
3418 }
3419 return id, openKey, true, nil
3420}
3421
3422// removeThreadMember drops a user from a thread (their membership + key copy).
3423// Note: the thread key isn't rotated, so this stops the server delivering future
3424// messages to them, but isn't forward-secret (rekey-on-leave is deferred).
3425func (db *DB) removeThreadMember(threadID, userID string) error {
3426 tx, err := db.sql.Begin()
3427 if err != nil {
3428 return err
3429 }
3430 defer tx.Rollback()
3431 if _, err := tx.Exec("DELETE FROM thread_members WHERE thread_id = ? AND user_id = ?", threadID, userID); err != nil {
3432 return err
3433 }
3434 if _, err := tx.Exec("DELETE FROM thread_keys WHERE thread_id = ? AND user_id = ?", threadID, userID); err != nil {
3435 return err
3436 }
3437 return tx.Commit()
3438}
3439
3440// setThreadHidden closes (hidden=true) or reopens (hidden=false) a chat for one
3441// user — a per-user marker, synced across their devices via getThreads.
3442func (db *DB) setThreadHidden(userID, threadID string, hidden bool) error {
3443 if hidden {
3444 _, err := db.sql.Exec(
3445 "INSERT OR IGNORE INTO hidden_threads(user_id, thread_id) VALUES(?, ?)", userID, threadID,
3446 )
3447 return err
3448 }
3449 _, err := db.sql.Exec("DELETE FROM hidden_threads WHERE user_id = ? AND thread_id = ?", userID, threadID)
3450 return err
3451}
3452
3453// setThreadPinned pins (pinned=true) or unpins (pinned=false) a chat for one
3454// user — a per-user marker, synced across their devices via getThreads.
3455func (db *DB) setThreadPinned(userID, threadID string, pinned bool) error {
3456 if pinned {
3457 _, err := db.sql.Exec(
3458 "INSERT OR IGNORE INTO pinned_threads(user_id, thread_id) VALUES(?, ?)", userID, threadID,
3459 )
3460 return err
3461 }
3462 _, err := db.sql.Exec("DELETE FROM pinned_threads WHERE user_id = ? AND thread_id = ?", userID, threadID)
3463 return err
3464}
3465
3466// userDMPrivacy returns a member's DM-privacy setting: "" (everyone, the
3467// default) or "nobody".
3468func (db *DB) userDMPrivacy(userID string) string {
3469 var v string
3470 db.sql.QueryRow("SELECT COALESCE(dm_privacy, '') FROM users WHERE id = ?", userID).Scan(&v)
3471 return v
3472}
3473
3474func (db *DB) setUserDMPrivacy(userID, v string) error {
3475 _, err := db.sql.Exec("UPDATE users SET dm_privacy = ? WHERE id = ?", v, userID)
3476 return err
3477}
3478
3479// closeDM marks a DM closed: the server stops accepting new traffic into it
3480// until reopenDM. Idempotent — the first closer wins the closed_by credit.
3481func (db *DB) closeDM(threadID, userID string) error {
3482 _, err := db.sql.Exec(
3483 "INSERT OR IGNORE INTO closed_dms(thread_id, closed_by, created_at) VALUES(?, ?, ?)",
3484 threadID, userID, time.Now().Unix(),
3485 )
3486 return err
3487}
3488
3489func (db *DB) reopenDM(threadID string) error {
3490 _, err := db.sql.Exec("DELETE FROM closed_dms WHERE thread_id = ?", threadID)
3491 return err
3492}
3493
3494// dmClosed reports whether a DM is closed, and by whom.
3495func (db *DB) dmClosed(threadID string) (bool, string) {
3496 var by string
3497 err := db.sql.QueryRow("SELECT closed_by FROM closed_dms WHERE thread_id = ?", threadID).Scan(&by)
3498 return err == nil, by
3499}
3500
3501// ---- reports + audit log (issue #578) ---------------------------------------
3502
3503// Report is a queue row: metadata + (when the caller is an admin member) the
3504// content blob sealed to THAT admin's key. ReporterCount/ReportedCount are the
3505// repeat signals the queue shows — how often the reporter files, how often the
3506// reported member is named — because testimony is judged on patterns.
3507type Report struct {
3508 ID string `json:"id"`
3509 CreatedAt int64 `json:"created_at"`
3510 ReporterID string `json:"reporter_id"`
3511 ReportedID string `json:"reported_id"`
3512 ThreadID string `json:"thread_id,omitempty"`
3513 Status string `json:"status"`
3514 Action string `json:"action,omitempty"`
3515 ResolvedBy string `json:"resolved_by,omitempty"`
3516 ResolvedAt int64 `json:"resolved_at,omitempty"`
3517 Blob string `json:"blob,omitempty"`
3518 ReporterCount int `json:"reporter_count"`
3519 ReportedCount int `json:"reported_count"`
3520 // InvitedBy is who brought the reported member in ('' when unknown) —
3521 // invite hygiene for the queue (issue #578).
3522 InvitedBy string `json:"invited_by,omitempty"`
3523}
3524
3525// createReport stores the queue row + the per-admin sealed content blobs in
3526// one transaction.
3527func (db *DB) createReport(reporterID, reportedID, threadID string, blobs map[string]string) (string, error) {
3528 id, err := randToken()
3529 if err != nil {
3530 return "", err
3531 }
3532 tx, err := db.sql.Begin()
3533 if err != nil {
3534 return "", err
3535 }
3536 defer tx.Rollback()
3537 if _, err := tx.Exec(
3538 "INSERT INTO reports(id, created_at, reporter_id, reported_id, thread_id) VALUES(?, ?, ?, ?, ?)",
3539 id, time.Now().Unix(), reporterID, reportedID, threadID,
3540 ); err != nil {
3541 return "", err
3542 }
3543 for adminID, blob := range blobs {
3544 if _, err := tx.Exec(
3545 "INSERT INTO report_blobs(report_id, admin_id, blob) VALUES(?, ?, ?)",
3546 id, adminID, blob,
3547 ); err != nil {
3548 return "", err
3549 }
3550 }
3551 return id, tx.Commit()
3552}
3553
3554// reportsSince counts a reporter's filings in a window — the content-blind
3555// brake on harassment-by-reporting.
3556func (db *DB) reportsSince(reporterID string, since int64) int {
3557 var n int
3558 db.sql.QueryRow("SELECT COUNT(*) FROM reports WHERE reporter_id = ? AND created_at >= ?", reporterID, since).Scan(&n)
3559 return n
3560}
3561
3562// listReports returns the queue (open first, newest first), each row carrying
3563// the caller-admin's own sealed blob and the repeat-signal counts.
3564func (db *DB) listReports(forAdminID string) ([]Report, error) {
3565 rows, err := db.sql.Query(`
3566 SELECT r.id, r.created_at, r.reporter_id, r.reported_id, r.thread_id,
3567 r.status, r.action, r.resolved_by, r.resolved_at,
3568 COALESCE((SELECT blob FROM report_blobs b WHERE b.report_id = r.id AND b.admin_id = ?), ''),
3569 (SELECT COUNT(*) FROM reports x WHERE x.reporter_id = r.reporter_id),
3570 (SELECT COUNT(*) FROM reports x WHERE x.reported_id = r.reported_id),
3571 COALESCE((SELECT invited_by FROM users u WHERE u.id = r.reported_id), '')
3572 FROM reports r
3573 ORDER BY (r.status = 'open') DESC, r.created_at DESC
3574 LIMIT 500`, forAdminID)
3575 if err != nil {
3576 return nil, err
3577 }
3578 defer rows.Close()
3579 out := []Report{}
3580 for rows.Next() {
3581 var r Report
3582 if err := rows.Scan(&r.ID, &r.CreatedAt, &r.ReporterID, &r.ReportedID, &r.ThreadID,
3583 &r.Status, &r.Action, &r.ResolvedBy, &r.ResolvedAt, &r.Blob,
3584 &r.ReporterCount, &r.ReportedCount, &r.InvitedBy); err != nil {
3585 return nil, err
3586 }
3587 out = append(out, r)
3588 }
3589 return out, rows.Err()
3590}
3591
3592// openReportCount is what the report notification says — "N waiting" is
3593// queue metadata the server already holds, never content.
3594func (db *DB) openReportCount() int {
3595 var n int
3596 db.sql.QueryRow("SELECT COUNT(*) FROM reports WHERE status = 'open'").Scan(&n)
3597 return n
3598}
3599
3600// resolveReport records the outcome. Reports whether the row was still open
3601// (a second resolver loses, keeping one outcome per report).
3602func (db *DB) resolveReport(id, action, resolvedBy string) (bool, error) {
3603 res, err := db.sql.Exec(
3604 "UPDATE reports SET status = 'resolved', action = ?, resolved_by = ?, resolved_at = ? WHERE id = ? AND status = 'open'",
3605 action, resolvedBy, time.Now().Unix(), id,
3606 )
3607 if err != nil {
3608 return false, err
3609 }
3610 n, err := res.RowsAffected()
3611 return n > 0, err
3612}
3613
3614// AuditEntry is one appended moderation action.
3615type AuditEntry struct {
3616 ID int64 `json:"id"`
3617 Ts int64 `json:"ts"`
3618 ActorID string `json:"actor_id"`
3619 Action string `json:"action"`
3620 SubjectID string `json:"subject_id,omitempty"`
3621 ThreadID string `json:"thread_id,omitempty"`
3622 ReportID string `json:"report_id,omitempty"`
3623 Detail string `json:"detail,omitempty"`
3624}
3625
3626// addAudit appends one moderation action. Best-effort at call sites (an audit
3627// write must never fail the action it records), so it returns no error.
3628func (db *DB) addAudit(actorID, action, subjectID, threadID, reportID, detail string) {
3629 _, err := db.sql.Exec(
3630 "INSERT INTO audit_log(ts, actor_id, action, subject_id, thread_id, report_id, detail) VALUES(?, ?, ?, ?, ?, ?, ?)",
3631 time.Now().Unix(), actorID, action, subjectID, threadID, reportID, detail,
3632 )
3633 if err != nil {
3634 log.Println("audit:", err)
3635 }
3636}
3637
3638// listAudit pages the log newest-first; before=0 starts at the top.
3639func (db *DB) listAudit(before int64, limit int) ([]AuditEntry, error) {
3640 if limit <= 0 || limit > 200 {
3641 limit = 100
3642 }
3643 q := "SELECT id, ts, actor_id, action, subject_id, thread_id, report_id, detail FROM audit_log"
3644 args := []any{}
3645 if before > 0 {
3646 q += " WHERE id < ?"
3647 args = append(args, before)
3648 }
3649 q += " ORDER BY id DESC LIMIT ?"
3650 args = append(args, limit)
3651 rows, err := db.sql.Query(q, args...)
3652 if err != nil {
3653 return nil, err
3654 }
3655 defer rows.Close()
3656 out := []AuditEntry{}
3657 for rows.Next() {
3658 var e AuditEntry
3659 if err := rows.Scan(&e.ID, &e.Ts, &e.ActorID, &e.Action, &e.SubjectID, &e.ThreadID, &e.ReportID, &e.Detail); err != nil {
3660 return nil, err
3661 }
3662 out = append(out, e)
3663 }
3664 return out, rows.Err()
3665}
3666
3667// setBlocked records (or with on=false clears) blocker→blocked. Blocking is
3668// one-directional in the record, mutual in effect (blockedEither).
3669func (db *DB) setBlocked(blockerID, blockedID string, on bool) error {
3670 if on {
3671 _, err := db.sql.Exec(
3672 "INSERT OR IGNORE INTO blocks(blocker_id, blocked_id, created_at) VALUES(?, ?, ?)",
3673 blockerID, blockedID, time.Now().Unix(),
3674 )
3675 return err
3676 }
3677 _, err := db.sql.Exec("DELETE FROM blocks WHERE blocker_id = ? AND blocked_id = ?", blockerID, blockedID)
3678 return err
3679}
3680
3681// blockedEither reports whether either member has blocked the other — the
3682// question every contact gate asks (new DM, reopen, group add).
3683func (db *DB) blockedEither(a, b string) bool {
3684 var x int
3685 return db.sql.QueryRow(
3686 "SELECT 1 FROM blocks WHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?)",
3687 a, b, b, a,
3688 ).Scan(&x) == nil
3689}
3690
3691// blocksOf lists who this member has blocked (their own list; nobody else's).
3692func (db *DB) blocksOf(userID string) ([]string, error) {
3693 rows, err := db.sql.Query("SELECT blocked_id FROM blocks WHERE blocker_id = ? ORDER BY created_at", userID)
3694 if err != nil {
3695 return nil, err
3696 }
3697 defer rows.Close()
3698 out := []string{}
3699 for rows.Next() {
3700 var u string
3701 if err := rows.Scan(&u); err != nil {
3702 return nil, err
3703 }
3704 out = append(out, u)
3705 }
3706 return out, rows.Err()
3707}
3708
3709// blockersOf returns the members who block this user — the push fan-out drops
3710// them so a blocked member's group messages never buzz the blocker.
3711func (db *DB) blockersOf(userID string) (map[string]bool, error) {
3712 rows, err := db.sql.Query("SELECT blocker_id FROM blocks WHERE blocked_id = ?", userID)
3713 if err != nil {
3714 return nil, err
3715 }
3716 defer rows.Close()
3717 out := map[string]bool{}
3718 for rows.Next() {
3719 var u string
3720 if err := rows.Scan(&u); err != nil {
3721 return nil, err
3722 }
3723 out[u] = true
3724 }
3725 return out, rows.Err()
3726}
3727
3728// addBan records a banned public key. Idempotent.
3729func (db *DB) addBan(publicKey, userID, bannedBy string) error {
3730 _, err := db.sql.Exec(
3731 "INSERT OR IGNORE INTO bans(public_key, user_id, banned_by, banned_at) VALUES(?, ?, ?, ?)",
3732 publicKey, userID, bannedBy, time.Now().Unix(),
3733 )
3734 return err
3735}
3736
3737// keyBanned reports whether a public key is banned — checked wherever a key
3738// registers (setPubkey covers every invite path, since a member exists only
3739// once their key does).
3740func (db *DB) keyBanned(publicKey string) bool {
3741 var x int
3742 return db.sql.QueryRow("SELECT 1 FROM bans WHERE public_key = ?", publicKey).Scan(&x) == nil
3743}
3744
3745// setThreadMuted mutes (muted=true) or unmutes (muted=false) a chat for one
3746// user — a per-user marker, synced across their devices via getThreads. The
3747// push fan-out consults it so a muted chat never buzzes any of their devices.
3748func (db *DB) setThreadMuted(userID, threadID string, muted bool) error {
3749 if muted {
3750 _, err := db.sql.Exec(
3751 "INSERT OR IGNORE INTO muted_threads(user_id, thread_id) VALUES(?, ?)", userID, threadID,
3752 )
3753 return err
3754 }
3755 _, err := db.sql.Exec("DELETE FROM muted_threads WHERE user_id = ? AND thread_id = ?", userID, threadID)
3756 return err
3757}
3758
3759// mutedUserIDs lists the users who have muted a thread, so push fan-out can
3760// leave them out.
3761func (db *DB) mutedUserIDs(threadID string) (map[string]bool, error) {
3762 rows, err := db.sql.Query("SELECT user_id FROM muted_threads WHERE thread_id = ?", threadID)
3763 if err != nil {
3764 return nil, err
3765 }
3766 defer rows.Close()
3767 out := map[string]bool{}
3768 for rows.Next() {
3769 var u string
3770 if err := rows.Scan(&u); err != nil {
3771 return nil, err
3772 }
3773 out[u] = true
3774 }
3775 return out, rows.Err()
3776}
3777
3778// setThreadRead advances one user's read watermark for a thread. Forward-only:
3779// a stale write from a lagging device can never wind the mark back and re-light
3780// unread dots the user's other devices already cleared. Reports whether the
3781// mark actually advanced (callers skip the fan-out when it didn't).
3782func (db *DB) setThreadRead(userID, threadID string, at int64) (bool, error) {
3783 res, err := db.sql.Exec(`
3784 INSERT INTO read_marks(user_id, thread_id, last_read_at) VALUES(?, ?, ?)
3785 ON CONFLICT(user_id, thread_id) DO UPDATE SET last_read_at = excluded.last_read_at
3786 WHERE excluded.last_read_at > last_read_at`, userID, threadID, at)
3787 if err != nil {
3788 return false, err
3789 }
3790 n, err := res.RowsAffected()
3791 return n > 0, err
3792}
3793
3794// clearThreadHidden reopens a thread for every user who had closed it — called on
3795// new activity so a closed chat returns to the list when it's messaged.
3796func (db *DB) clearThreadHidden(threadID string) error {
3797 _, err := db.sql.Exec("DELETE FROM hidden_threads WHERE thread_id = ?", threadID)
3798 return err
3799}
3800
3801// setThreadName updates a group's (client-encrypted) name. Server-blind.
3802func (db *DB) setThreadName(threadID, nameBlob string) error {
3803 _, err := db.sql.Exec("UPDATE threads SET name_blob = ? WHERE id = ?", nameBlob, threadID)
3804 return err
3805}
3806
3807// setThreadAvatar sets a group's photo (an encrypted blob id, or "" to clear).
3808func (db *DB) setThreadAvatar(threadID, blobID string) error {
3809 var b any
3810 if blobID != "" {
3811 b = blobID
3812 }
3813 _, err := db.sql.Exec("UPDATE threads SET avatar_blob = ? WHERE id = ?", b, threadID)
3814 return err
3815}
3816
3817// memberUserIDs returns the user ids in a thread (for fan-out / rekey).
3818func (db *DB) memberUserIDs(threadID string) ([]string, error) {
3819 q, args := "SELECT user_id FROM thread_members WHERE thread_id = ?", []any{threadID}
3820 // The space thread's membership is implicit — every non-agent member, plus
3821 // any explicit rows (a deliberately-added bot). Mirrors threadMember.
3822 if db.threadKind(threadID) == "space" {
3823 q = "SELECT id FROM users WHERE COALESCE(is_agent, 0) = 0 UNION " + q
3824 }
3825 rows, err := db.sql.Query(q, args...)
3826 if err != nil {
3827 return nil, err
3828 }
3829 defer rows.Close()
3830 out := []string{}
3831 for rows.Next() {
3832 var u string
3833 if err := rows.Scan(&u); err != nil {
3834 return nil, err
3835 }
3836 out = append(out, u)
3837 }
3838 return out, rows.Err()
3839}
3840
3841// canonPair orders a user pair so there's exactly one dm_pairs row per pair.
3842func canonPair(a, b string) (string, string) {
3843 if a > b {
3844 return b, a
3845 }
3846 return a, b
3847}
3848
3849// dmThreadFor returns the existing DM thread between two users, if any (so DMs
3850// are deduped — exactly one per pair).
3851func (db *DB) dmThreadFor(a, b string) (string, bool, error) {
3852 lo, hi := canonPair(a, b)
3853 var id string
3854 err := db.sql.QueryRow("SELECT thread_id FROM dm_pairs WHERE user_a = ? AND user_b = ?", lo, hi).Scan(&id)
3855 if err == sql.ErrNoRows {
3856 return "", false, nil
3857 }
3858 if err != nil {
3859 return "", false, err
3860 }
3861 return id, true, nil
3862}
3863
3864func (db *DB) recordDMPair(a, b, threadID string) error {
3865 lo, hi := canonPair(a, b)
3866 _, err := db.sql.Exec("INSERT INTO dm_pairs(user_a, user_b, thread_id) VALUES(?, ?, ?)", lo, hi, threadID)
3867 return err
3868}
3869
3870// otherThreadMember returns the other member of a 2-person thread — the member
3871// whose id isn't notUserID. Used to find the inviter of a pending DM.
3872func (db *DB) otherThreadMember(threadID, notUserID string) (string, bool) {
3873 var uid string
3874 err := db.sql.QueryRow(
3875 "SELECT user_id FROM thread_members WHERE thread_id = ? AND user_id <> ? LIMIT 1", threadID, notUserID,
3876 ).Scan(&uid)
3877 return uid, err == nil
3878}
3879
3880// adoptUserInvite handles the case where the person opening a user invite is
3881// ALREADY a member on this device: rather than the placeholder becoming them, we
3882// wire their existing account into the DM. If they already share a DM with the
3883// inviter we reuse it (and drop the pending one); otherwise we swap the placeholder
3884// out of the pending thread for the real user. The placeholder — keyless, with no
3885// messages of its own — is deleted either way. wrappedSelf is the thread key
3886// wrapped to the existing user's public key. Returns the thread to open. ok=false
3887// (no error) if the invite is unusable or lost the single-use race.
3888func (db *DB) adoptUserInvite(inviteID, existingUserID, wrappedSelf, claimSecret string) (string, bool, error) {
3889 ki, found, err := db.getKeyInvite(inviteID)
3890 if err != nil || !found || ki.Used || ki.Expired || ki.ThreadID == "" {
3891 return "", false, err
3892 }
3893 if !validClaimSecret(ki.ClaimHash, claimSecret) {
3894 return "", false, nil
3895 }
3896 placeholder := ki.UserID
3897 inviter, ok := db.otherThreadMember(ki.ThreadID, placeholder)
3898 if !ok || inviter == existingUserID {
3899 return "", false, nil // malformed, or you can't adopt your own invite
3900 }
3901 existing, haveDM, err := db.dmThreadFor(inviter, existingUserID)
3902 if err != nil {
3903 return "", false, err
3904 }
3905 tx, err := db.sql.Begin()
3906 if err != nil {
3907 return "", false, err
3908 }
3909 defer tx.Rollback()
3910 // Single-use guard: claim the invite; bail if someone beat us to it.
3911 res, err := tx.Exec("UPDATE key_invites SET used_at = ? WHERE id = ? AND used_at IS NULL", time.Now().Unix(), inviteID)
3912 if err != nil {
3913 return "", false, err
3914 }
3915 if n, _ := res.RowsAffected(); n == 0 {
3916 return "", false, nil
3917 }
3918 oldLo, oldHi := canonPair(inviter, placeholder)
3919 target := ki.ThreadID
3920 if haveDM && existing != ki.ThreadID {
3921 // Already have a DM together — discard the pending thread entirely.
3922 target = existing
3923 for _, q := range []string{
3924 "DELETE FROM thread_keys WHERE thread_id = ?",
3925 "DELETE FROM thread_members WHERE thread_id = ?",
3926 "DELETE FROM threads WHERE id = ?",
3927 } {
3928 if _, err := tx.Exec(q, ki.ThreadID); err != nil {
3929 return "", false, err
3930 }
3931 }
3932 if _, err := tx.Exec("DELETE FROM dm_pairs WHERE user_a = ? AND user_b = ?", oldLo, oldHi); err != nil {
3933 return "", false, err
3934 }
3935 } else {
3936 // Swap the placeholder out of the pending thread for the real user.
3937 now := time.Now().Unix()
3938 if _, err := tx.Exec("DELETE FROM thread_members WHERE thread_id = ? AND user_id = ?", ki.ThreadID, placeholder); err != nil {
3939 return "", false, err
3940 }
3941 if _, err := tx.Exec("DELETE FROM thread_keys WHERE thread_id = ? AND user_id = ?", ki.ThreadID, placeholder); err != nil {
3942 return "", false, err
3943 }
3944 if _, err := tx.Exec("INSERT OR IGNORE INTO thread_members(thread_id, user_id, created_at) VALUES(?, ?, ?)", ki.ThreadID, existingUserID, now); err != nil {
3945 return "", false, err
3946 }
3947 if _, err := tx.Exec("INSERT OR REPLACE INTO thread_keys(thread_id, user_id, wrapped) VALUES(?, ?, ?)", ki.ThreadID, existingUserID, wrappedSelf); err != nil {
3948 return "", false, err
3949 }
3950 // Re-key the dm_pair from (inviter, placeholder) to (inviter, existing).
3951 newLo, newHi := canonPair(inviter, existingUserID)
3952 if _, err := tx.Exec("DELETE FROM dm_pairs WHERE user_a = ? AND user_b = ?", oldLo, oldHi); err != nil {
3953 return "", false, err
3954 }
3955 if _, err := tx.Exec("INSERT OR IGNORE INTO dm_pairs(user_a, user_b, thread_id) VALUES(?, ?, ?)", newLo, newHi, ki.ThreadID); err != nil {
3956 return "", false, err
3957 }
3958 }
3959 // The placeholder never joined (no key, no messages) — remove it.
3960 if _, err := tx.Exec("DELETE FROM users WHERE id = ?", placeholder); err != nil {
3961 return "", false, err
3962 }
3963 return target, true, tx.Commit()
3964}
3965
3966// cancelUserInvite tears down an unclaimed pending DM: the thread, its keys and
3967// members, the placeholder user, the DM pair, and the invite itself. Only the
3968// inviter (a real, keyed member) may cancel, and only while the placeholder hasn't
3969// joined (still keyless). ok=false (no error) if it doesn't qualify.
3970func (db *DB) cancelUserInvite(threadID, inviterID string) (bool, error) {
3971 // The invite for this pending thread, plus the placeholder it belongs to.
3972 var inviteID, placeholder string
3973 err := db.sql.QueryRow(
3974 "SELECT id, user_id FROM key_invites WHERE thread_id = ? AND used_at IS NULL", threadID,
3975 ).Scan(&inviteID, &placeholder)
3976 if err == sql.ErrNoRows {
3977 return false, nil
3978 }
3979 if err != nil {
3980 return false, err
3981 }
3982 // Guard: caller must be the (keyed) inviter, and the placeholder must not have
3983 // joined (a joined member has a public key — cancelling then would delete a real
3984 // person and their DM).
3985 if !db.threadMember(threadID, inviterID) {
3986 return false, nil
3987 }
3988 var pub sql.NullString
3989 if err := db.sql.QueryRow("SELECT public_key FROM users WHERE id = ?", placeholder).Scan(&pub); err != nil {
3990 return false, err
3991 }
3992 if pub.Valid && pub.String != "" {
3993 return false, nil // they joined — not a pending invite anymore
3994 }
3995 tx, err := db.sql.Begin()
3996 if err != nil {
3997 return false, err
3998 }
3999 defer tx.Rollback()
4000 for _, s := range []struct {
4001 q string
4002 arg string
4003 }{
4004 {"DELETE FROM key_invites WHERE thread_id = ?", threadID},
4005 {"DELETE FROM thread_keys WHERE thread_id = ?", threadID},
4006 {"DELETE FROM thread_members WHERE thread_id = ?", threadID},
4007 {"DELETE FROM dm_pairs WHERE thread_id = ?", threadID},
4008 {"DELETE FROM threads WHERE id = ?", threadID},
4009 {"DELETE FROM users WHERE id = ?", placeholder},
4010 } {
4011 if _, err := tx.Exec(s.q, s.arg); err != nil {
4012 return false, err
4013 }
4014 }
4015 return true, tx.Commit()
4016}
4017
4018// ---- messages -----------------------------------------------------------
4019
4020type Message struct {
4021 ID int64 `json:"id"`
4022 CreatedAt int64 `json:"created_at"`
4023 Blob string `json:"blob"`
4024 EditedAt int64 `json:"edited_at,omitempty"`
4025 // Non-zero marks this row a delete tombstone: the id of the message it
4026 // unsent (#581). Tombstone rows have an empty blob; created_at is the
4027 // deletion time. Old clients drop empty-blob rows, so this is additive.
4028 TombstoneOf int64 `json:"tombstone_of,omitempty"`
4029}
4030
4031func (db *DB) insertMessage(threadID, blob, authorHash string) (Message, error) {
4032 now := time.Now().Unix()
4033 // A transaction so the journal row (tamper evidence, checkpoint.go) can
4034 // never be missing for a message that exists — a gap would read as
4035 // tampering to a verifier.
4036 tx, err := db.sql.Begin()
4037 if err != nil {
4038 return Message{}, err
4039 }
4040 defer tx.Rollback()
4041 res, err := tx.Exec(
4042 "INSERT INTO messages(created_at, blob, author_hash, thread_id) VALUES(?, ?, ?, ?)", now, blob, authorHash, threadID,
4043 )
4044 if err != nil {
4045 return Message{}, err
4046 }
4047 id, err := res.LastInsertId()
4048 if err != nil {
4049 return Message{}, err
4050 }
4051 if err := appendJournalTx(tx, now, "msg", id, threadID, ciphertextHash(blob)); err != nil {
4052 return Message{}, err
4053 }
4054 if err := tx.Commit(); err != nil {
4055 return Message{}, err
4056 }
4057 // New activity reopens the thread for anyone who'd closed it (best-effort).
4058 _ = db.clearThreadHidden(threadID)
4059 return Message{ID: id, CreatedAt: now, Blob: blob}, nil
4060}
4061
4062// editMessage replaces a message's ciphertext in place, but only if authorHash
4063// matches the original sender and the message is still within the edit window.
4064// Both checks live in the WHERE clause so they're enforced atomically by the DB,
4065// never by client-supplied claims. Returns ok=false (no error) when the edit is
4066// not permitted — wrong author, too old, or unknown id.
4067func (db *DB) editMessage(id int64, blob, authorHash string, window time.Duration) (Message, bool, error) {
4068 now := time.Now()
4069 cutoff := now.Add(-window).Unix()
4070 tx, err := db.sql.Begin()
4071 if err != nil {
4072 return Message{}, false, err
4073 }
4074 defer tx.Rollback()
4075 res, err := tx.Exec(
4076 "UPDATE messages SET blob = ?, edited_at = ? WHERE id = ? AND author_hash = ? AND created_at >= ?",
4077 blob, now.Unix(), id, authorHash, cutoff,
4078 )
4079 if err != nil {
4080 return Message{}, false, err
4081 }
4082 n, err := res.RowsAffected()
4083 if err != nil {
4084 return Message{}, false, err
4085 }
4086 if n == 0 {
4087 return Message{}, false, tx.Commit()
4088 }
4089 // Journal the edit (tamper evidence): the new ciphertext's hash — the
4090 // pre-edit hash is already the message's previous journal row.
4091 var threadID string
4092 if err := tx.QueryRow("SELECT COALESCE(thread_id, '') FROM messages WHERE id = ?", id).Scan(&threadID); err != nil {
4093 return Message{}, false, err
4094 }
4095 if err := appendJournalTx(tx, now.Unix(), "edit", id, threadID, ciphertextHash(blob)); err != nil {
4096 return Message{}, false, err
4097 }
4098 if err := tx.Commit(); err != nil {
4099 return Message{}, false, err
4100 }
4101 return Message{ID: id, Blob: blob, EditedAt: now.Unix()}, true, nil
4102}
4103
4104// deleteMessage hard-deletes a message if authorHash is the author —
4105// unsend-style, so the ciphertext is actually gone, not just hidden.
4106// Returns whether a row was removed (false = not the author / unknown id).
4107// Reactions live inside encrypted message events, so there is nothing else
4108// to clean up here.
4109//
4110// In the same transaction it inserts a tombstone row (#581): fresh id, empty
4111// blob, tombstone_of = the deleted id. The live "delete" broadcast reaches
4112// only connected clients; the tombstone's fresh id lands above every
4113// watermark, so clients that were offline replay the delete from the
4114// ordinary `after:` catch-up with no new sync machinery. (Tombstones
4115// themselves can't be deleted or edited: their author_hash is NULL, which
4116// matches no caller.)
4117func (db *DB) deleteMessage(id int64, authorHash string) (bool, error) {
4118 tx, err := db.sql.Begin()
4119 if err != nil {
4120 return false, err
4121 }
4122 defer tx.Rollback()
4123 var threadID sql.NullString
4124 err = tx.QueryRow("SELECT thread_id FROM messages WHERE id = ? AND author_hash = ?", id, authorHash).Scan(&threadID)
4125 if err == sql.ErrNoRows {
4126 return false, nil
4127 }
4128 if err != nil {
4129 return false, err
4130 }
4131 // Journal before the row is gone (tamper evidence) — stays atomic with the
4132 // DELETE below inside this transaction.
4133 if err := journalMessageDeletesTx(tx, "id = ? AND author_hash = ?", id, authorHash); err != nil {
4134 return false, err
4135 }
4136 // The DELETE re-checks author_hash and gates on RowsAffected, preserving the
4137 // old single-statement invariant: true means THIS call removed the row. A
4138 // raced duplicate (double-tap, retried frame) loses here and mints no
4139 // second tombstone.
4140 res, err := tx.Exec("DELETE FROM messages WHERE id = ? AND author_hash = ?", id, authorHash)
4141 if err != nil {
4142 return false, err
4143 }
4144 if n, err := res.RowsAffected(); err != nil || n == 0 {
4145 return false, err
4146 }
4147 // A legacy pre-thread_id row (NULL) is unreachable by thread reads, so no
4148 // client ever rendered it — skip the tombstone rather than insert one no
4149 // catch-up query could deliver.
4150 if threadID.Valid {
4151 now := time.Now().Unix()
4152 res, err := tx.Exec(
4153 "INSERT INTO messages(created_at, blob, thread_id, tombstone_of) VALUES(?, '', ?, ?)",
4154 now, threadID.String, id,
4155 )
4156 if err != nil {
4157 return false, err
4158 }
4159 // The tombstone is a real message row (clients replay it from catch-up),
4160 // so it gets a journal row like any other insert — an unexplained row
4161 // would read as tampering to a verifier.
4162 tombID, err := res.LastInsertId()
4163 if err != nil {
4164 return false, err
4165 }
4166 if err := appendJournalTx(tx, now, "msg", tombID, threadID.String, ciphertextHash("")); err != nil {
4167 return false, err
4168 }
4169 }
4170 return true, tx.Commit()
4171}
4172
4173// messagesBefore returns the `limit` messages immediately older than `before`
4174// (id < before), ascending — used to paginate history upward.
4175func (db *DB) messagesBefore(threadID string, before int64, limit int) ([]Message, error) {
4176 rows, err := db.sql.Query(
4177 "SELECT id, created_at, blob, COALESCE(edited_at, 0), COALESCE(tombstone_of, 0) FROM (SELECT id, created_at, blob, edited_at, tombstone_of FROM messages WHERE thread_id = ? AND id < ? ORDER BY id DESC LIMIT ?) ORDER BY id ASC",
4178 threadID, before, limit,
4179 )
4180 if err != nil {
4181 return nil, err
4182 }
4183 defer rows.Close()
4184 out := []Message{}
4185 for rows.Next() {
4186 var m Message
4187 if err := rows.Scan(&m.ID, &m.CreatedAt, &m.Blob, &m.EditedAt, &m.TombstoneOf); err != nil {
4188 return nil, err
4189 }
4190 out = append(out, m)
4191 }
4192 return out, rows.Err()
4193}
4194
4195// messagesAfter returns messages with id > after (ascending). When after is 0,
4196// it returns the most recent `limit` messages instead of the entire history.
4197func (db *DB) messagesAfter(threadID string, after int64, limit int) ([]Message, error) {
4198 var rows *sql.Rows
4199 var err error
4200 if after == 0 {
4201 rows, err = db.sql.Query(
4202 "SELECT id, created_at, blob, COALESCE(edited_at, 0), COALESCE(tombstone_of, 0) FROM (SELECT id, created_at, blob, edited_at, tombstone_of FROM messages WHERE thread_id = ? ORDER BY id DESC LIMIT ?) ORDER BY id ASC",
4203 threadID, limit,
4204 )
4205 } else {
4206 rows, err = db.sql.Query(
4207 "SELECT id, created_at, blob, COALESCE(edited_at, 0), COALESCE(tombstone_of, 0) FROM messages WHERE thread_id = ? AND id > ? ORDER BY id ASC LIMIT ?",
4208 threadID, after, limit,
4209 )
4210 }
4211 if err != nil {
4212 return nil, err
4213 }
4214 defer rows.Close()
4215 out := []Message{}
4216 for rows.Next() {
4217 var m Message
4218 if err := rows.Scan(&m.ID, &m.CreatedAt, &m.Blob, &m.EditedAt, &m.TombstoneOf); err != nil {
4219 return nil, err
4220 }
4221 out = append(out, m)
4222 }
4223 return out, rows.Err()
4224}
4225
4226// ---- blobs (encrypted attachments) --------------------------------------
4227//
4228// An uploaded file is encrypted in the browser before it ever reaches here, so
4229// the server stores only opaque bytes — no filename, type, or content. The
4230// referencing message (also encrypted) carries the metadata.
4231
4232// recordBlob inserts the metadata row for a store-backed blob whose bytes the
4233// caller has already committed to db.blobs under id. `data` is stored empty —
4234// the row exists only for quota accounting and thread-scoped GC. threadID ties
4235// the blob to its thread (empty → NULL, e.g. profile-avatar uploads that aren't
4236// thread-scoped and are replaced rather than message-GC'd).
4237func (db *DB) recordBlob(id, threadID string, size int64, sha256Hex string) error {
4238 var tid any
4239 if threadID != "" {
4240 tid = threadID
4241 }
4242 _, err := db.sql.Exec(
4243 "INSERT INTO blobs(id, data, size, thread_id, created_at, sha256) VALUES(?, X'', ?, ?, ?, ?)",
4244 id, size, tid, time.Now().Unix(), sha256Hex,
4245 )
4246 return err
4247}
4248
4249// putBlob stores small in-memory blob bytes in the store + records the row.
4250// Kept for callers that already hold the bytes (tests, any non-streaming path);
4251// the HTTP upload path streams through a staged write instead (see uploadBlob).
4252func (db *DB) putBlob(id, threadID string, data []byte) error {
4253 w, err := db.blobs.create()
4254 if err != nil {
4255 return err
4256 }
4257 if _, err := w.Write(data); err != nil {
4258 w.abort()
4259 return err
4260 }
4261 if err := w.commit(id); err != nil {
4262 return err
4263 }
4264 sum := sha256.Sum256(data)
4265 if err := db.recordBlob(id, threadID, int64(len(data)), hex.EncodeToString(sum[:])); err != nil {
4266 _ = db.blobs.remove(id)
4267 return err
4268 }
4269 return nil
4270}
4271
4272// legacyBlob returns the bytes of a pre-disk (column-stored) blob, or nil if the
4273// blob is store-backed (empty column) or unknown. Only the HTTP handler's
4274// fallback path calls this, after the store reports no such blob.
4275func (db *DB) legacyBlob(id string) ([]byte, error) {
4276 var data []byte
4277 err := db.sql.QueryRow("SELECT data FROM blobs WHERE id = ?", id).Scan(&data)
4278 if err == sql.ErrNoRows {
4279 return nil, nil
4280 }
4281 if err != nil {
4282 return nil, err
4283 }
4284 if len(data) == 0 {
4285 return nil, nil // disk-backed row — bytes live on disk, not here
4286 }
4287 return data, nil
4288}
4289
4290// blobBytesTotal is the sum of all stored blob sizes — the figure the
4291// per-instance storage quota is checked against.
4292func (db *DB) blobBytesTotal() (int64, error) {
4293 var n sql.NullInt64
4294 err := db.sql.QueryRow("SELECT COALESCE(SUM(size), 0) FROM blobs").Scan(&n)
4295 return n.Int64, err
4296}
4297
4298// deleteBlobs removes the given blobs *scoped to one thread*: a row only goes if
4299// its thread_id matches, so a client reporting ids to GC when it deletes its own
4300// message can never drop another thread's (or another member's) blobs. Stored
4301// bytes are removed best-effort for each row actually removed.
4302func (db *DB) deleteBlobs(threadID string, ids []string) error {
4303 for _, id := range ids {
4304 if !validBlobID(id) {
4305 continue
4306 }
4307 res, err := db.sql.Exec("DELETE FROM blobs WHERE id = ? AND thread_id = ?", id, threadID)
4308 if err != nil {
4309 return err
4310 }
4311 if n, _ := res.RowsAffected(); n > 0 {
4312 _ = db.blobs.remove(id)
4313 }
4314 }
4315 return nil
4316}
4317
4318// deleteThreadBlobs unlinks + removes every blob tagged to a thread, called when
4319// the whole thread is torn down (the messages referencing them are going too).
4320func (db *DB) deleteThreadBlobs(threadID string) error {
4321 rows, err := db.sql.Query("SELECT id FROM blobs WHERE thread_id = ?", threadID)
4322 if err != nil {
4323 return err
4324 }
4325 var ids []string
4326 for rows.Next() {
4327 var id string
4328 if err := rows.Scan(&id); err != nil {
4329 rows.Close()
4330 return err
4331 }
4332 ids = append(ids, id)
4333 }
4334 rows.Close()
4335 if len(ids) == 0 {
4336 return nil
4337 }
4338 if _, err := db.sql.Exec("DELETE FROM blobs WHERE thread_id = ?", threadID); err != nil {
4339 return err
4340 }
4341 for _, id := range ids {
4342 _ = db.blobs.remove(id)
4343 }
4344 return nil
4345}
4346
4347// ---- scheduled messages (mini-app "future messages") ---------------------
4348//
4349// A scheduled message is client-encrypted ciphertext the server holds until
4350// deliver_at, then moves into the thread's message log (one transaction, so a
4351// crash can't deliver twice) and fans out like any posted message. The server
4352// only ever learns (thread, time) — never what is scheduled.
4353
4354type ScheduledMessage struct {
4355 ID string `json:"id"`
4356 ThreadID string `json:"thread_id"`
4357 DeliverAt int64 `json:"deliver_at"`
4358 Blob string `json:"blob"`
4359 AuthorID string `json:"-"`
4360}
4361
4362func (db *DB) createScheduledMessage(threadID, blob, authorID string, deliverAt int64) (string, error) {
4363 id, err := randToken()
4364 if err != nil {
4365 return "", err
4366 }
4367 _, err = db.sql.Exec(
4368 "INSERT INTO scheduled_messages(id, thread_id, deliver_at, blob, author_id, created_at) VALUES(?, ?, ?, ?, ?, ?)",
4369 id, threadID, deliverAt, blob, authorID, time.Now().Unix(),
4370 )
4371 return id, err
4372}
4373
4374// scheduledMessageThread returns which thread a pending schedule belongs to, so
4375// the cancel handler can check the caller's membership of THAT thread. A missing
4376// row reports (found=false, nil); a real DB failure is propagated as an error so
4377// the caller can't mistake an outage for "already cancelled" and skip the delete.
4378func (db *DB) scheduledMessageThread(id string) (string, bool, error) {
4379 var tid string
4380 err := db.sql.QueryRow("SELECT thread_id FROM scheduled_messages WHERE id = ?", id).Scan(&tid)
4381 if err == sql.ErrNoRows {
4382 return "", false, nil
4383 }
4384 if err != nil {
4385 return "", false, err
4386 }
4387 return tid, true, nil
4388}
4389
4390func (db *DB) deleteScheduledMessage(id string) error {
4391 _, err := db.sql.Exec("DELETE FROM scheduled_messages WHERE id = ?", id)
4392 return err
4393}
4394
4395// listScheduledMessages returns a thread's pending schedules (soonest first) so a
4396// client can reconcile — e.g. a fresh device matching schedule ids to app items.
4397func (db *DB) listScheduledMessages(threadID string) ([]ScheduledMessage, error) {
4398 rows, err := db.sql.Query(
4399 "SELECT id, thread_id, deliver_at, blob FROM scheduled_messages WHERE thread_id = ? ORDER BY deliver_at ASC",
4400 threadID,
4401 )
4402 if err != nil {
4403 return nil, err
4404 }
4405 defer rows.Close()
4406 out := []ScheduledMessage{}
4407 for rows.Next() {
4408 var sm ScheduledMessage
4409 if err := rows.Scan(&sm.ID, &sm.ThreadID, &sm.DeliverAt, &sm.Blob); err != nil {
4410 return nil, err
4411 }
4412 out = append(out, sm)
4413 }
4414 return out, rows.Err()
4415}
4416
4417// countScheduledMessages caps runaway scheduling per thread (see postSchedule).
4418func (db *DB) countScheduledMessages(threadID string) int {
4419 var n int
4420 db.sql.QueryRow("SELECT COUNT(*) FROM scheduled_messages WHERE thread_id = ?", threadID).Scan(&n)
4421 return n
4422}
4423
4424// nextScheduledDeliverAt returns the earliest pending deliver_at (unix
4425// seconds), or 0 when nothing is scheduled. Delivered schedules are deleted
4426// (deliverScheduledMessage), so MIN over the whole table IS the next due time —
4427// this feeds the router's due-time index so a hibernating instance is woken in
4428// time to fire it (see getNextDue).
4429func (db *DB) nextScheduledDeliverAt() (int64, error) {
4430 var due int64
4431 err := db.sql.QueryRow("SELECT COALESCE(MIN(deliver_at), 0) FROM scheduled_messages").Scan(&due)
4432 return due, err
4433}
4434
4435// dueScheduledMessages returns schedules whose time has come, oldest first.
4436func (db *DB) dueScheduledMessages(now int64, limit int) ([]ScheduledMessage, error) {
4437 rows, err := db.sql.Query(
4438 "SELECT id, thread_id, deliver_at, blob, author_id FROM scheduled_messages WHERE deliver_at <= ? ORDER BY deliver_at ASC LIMIT ?",
4439 now, limit,
4440 )
4441 if err != nil {
4442 return nil, err
4443 }
4444 defer rows.Close()
4445 out := []ScheduledMessage{}
4446 for rows.Next() {
4447 var sm ScheduledMessage
4448 if err := rows.Scan(&sm.ID, &sm.ThreadID, &sm.DeliverAt, &sm.Blob, &sm.AuthorID); err != nil {
4449 return nil, err
4450 }
4451 out = append(out, sm)
4452 }
4453 return out, rows.Err()
4454}
4455
4456// deliverScheduledMessage moves one due schedule into the message log: insert the
4457// message and delete the schedule row in a single transaction, so a crash between
4458// the two can't deliver the same schedule twice on restart. The WHERE-guarded
4459// delete also makes concurrent sweeps safe: only the sweep that actually removes
4460// the row (ok=true) broadcasts.
4461func (db *DB) deliverScheduledMessage(sm ScheduledMessage) (Message, bool, error) {
4462 tx, err := db.sql.Begin()
4463 if err != nil {
4464 return Message{}, false, err
4465 }
4466 defer tx.Rollback()
4467 res, err := tx.Exec("DELETE FROM scheduled_messages WHERE id = ?", sm.ID)
4468 if err != nil {
4469 return Message{}, false, err
4470 }
4471 if n, _ := res.RowsAffected(); n == 0 {
4472 return Message{}, false, tx.Commit() // another sweep won the race
4473 }
4474 now := time.Now().Unix()
4475 ins, err := tx.Exec(
4476 "INSERT INTO messages(created_at, blob, author_hash, thread_id) VALUES(?, ?, ?, ?)",
4477 now, sm.Blob, sm.AuthorID, sm.ThreadID,
4478 )
4479 if err != nil {
4480 return Message{}, false, err
4481 }
4482 id, err := ins.LastInsertId()
4483 if err != nil {
4484 return Message{}, false, err
4485 }
4486 // A delivered schedule is an ordinary message insert to the journal
4487 // (tamper evidence) — verifiers don't care how a message arrived.
4488 if err := appendJournalTx(tx, now, "msg", id, sm.ThreadID, ciphertextHash(sm.Blob)); err != nil {
4489 return Message{}, false, err
4490 }
4491 if err := tx.Commit(); err != nil {
4492 return Message{}, false, err
4493 }
4494 // New activity reopens the thread for anyone who'd closed it (best-effort),
4495 // mirroring insertMessage.
4496 _ = db.clearThreadHidden(sm.ThreadID)
4497 return Message{ID: id, CreatedAt: now, Blob: sm.Blob}, true, nil
4498}
4499
4500// ---- thread state (mini-app snapshots) ------------------------------------
4501
4502// getThreadState returns a slot's blob + version (ok=false when none saved yet).
4503func (db *DB) getThreadState(threadID, slot string) (string, int64, bool, error) {
4504 var blob string
4505 var version int64
4506 err := db.sql.QueryRow(
4507 "SELECT blob, version FROM thread_state WHERE thread_id = ? AND slot = ?", threadID, slot,
4508 ).Scan(&blob, &version)
4509 if err == sql.ErrNoRows {
4510 return "", 0, false, nil
4511 }
4512 if err != nil {
4513 return "", 0, false, err
4514 }
4515 return blob, version, true, nil
4516}
4517
4518// putThreadState writes a slot iff the caller's expected version matches what's
4519// stored (0 = create). Returns the new version, or ok=false on a version
4520// conflict — the caller reloads, refolds, and retries. Both branches enforce the
4521// check in the statement itself, so two racing writers can't both win.
4522func (db *DB) putThreadState(threadID, slot, blob string, expect int64) (int64, bool, error) {
4523 now := time.Now().Unix()
4524 if expect == 0 {
4525 res, err := db.sql.Exec(
4526 "INSERT INTO thread_state(thread_id, slot, blob, version, updated_at) VALUES(?, ?, ?, 1, ?) ON CONFLICT(thread_id, slot) DO NOTHING",
4527 threadID, slot, blob, now,
4528 )
4529 if err != nil {
4530 return 0, false, err
4531 }
4532 if n, _ := res.RowsAffected(); n == 0 {
4533 return 0, false, nil // someone created it first
4534 }
4535 return 1, true, nil
4536 }
4537 res, err := db.sql.Exec(
4538 "UPDATE thread_state SET blob = ?, version = version + 1, updated_at = ? WHERE thread_id = ? AND slot = ? AND version = ?",
4539 blob, now, threadID, slot, expect,
4540 )
4541 if err != nil {
4542 return 0, false, err
4543 }
4544 if n, _ := res.RowsAffected(); n == 0 {
4545 return 0, false, nil // stale version — conflict
4546 }
4547 return expect + 1, true, nil
4548}
4549
4550// ---- connected services (docs/connected-services.md) --------------------
4551//
4552// One opaque, client-encrypted credential blob per (member, service). The server
4553// keys/indexes it but never reads it. Keyed by user_id like instance_keys.
4554
4555// putConnectedService upserts a member's blob for a service.
4556func (db *DB) putConnectedService(userID, service, blob string) error {
4557 _, err := db.sql.Exec(
4558 "INSERT INTO connected_services(user_id, service, blob, updated_at) VALUES(?, ?, ?, ?) "+
4559 "ON CONFLICT(user_id, service) DO UPDATE SET blob = excluded.blob, updated_at = excluded.updated_at",
4560 userID, service, blob, time.Now().Unix(),
4561 )
4562 return err
4563}
4564
4565// getConnectedService returns a member's blob for a service (ok=false if none).
4566func (db *DB) getConnectedService(userID, service string) (string, bool, error) {
4567 var blob string
4568 err := db.sql.QueryRow(
4569 "SELECT blob FROM connected_services WHERE user_id = ? AND service = ?", userID, service,
4570 ).Scan(&blob)
4571 if err == sql.ErrNoRows {
4572 return "", false, nil
4573 }
4574 if err != nil {
4575 return "", false, err
4576 }
4577 return blob, true, nil
4578}
4579
4580// deleteConnectedService removes a member's connection to a service (disconnect).
4581func (db *DB) deleteConnectedService(userID, service string) error {
4582 _, err := db.sql.Exec("DELETE FROM connected_services WHERE user_id = ? AND service = ?", userID, service)
4583 return err
4584}
4585
4586// listConnectedServices returns the service names a member has connected.
4587func (db *DB) listConnectedServices(userID string) ([]string, error) {
4588 rows, err := db.sql.Query("SELECT service FROM connected_services WHERE user_id = ? ORDER BY service", userID)
4589 if err != nil {
4590 return nil, err
4591 }
4592 defer rows.Close()
4593 var out []string
4594 for rows.Next() {
4595 var svc string
4596 if err := rows.Scan(&svc); err != nil {
4597 return nil, err
4598 }
4599 out = append(out, svc)
4600 }
4601 return out, rows.Err()
4602}
4603
4604// ---- custom apps (customapps.go) -----------------------------------------
4605//
4606// Admin-installed mini-app module source, served to members like the compiled-in
4607// apps. The DB is the admin console's store and shadows a same-named file in
4608// LCHAT_APPS_DIR — the console is authoritative; the disk dir stays a dev tool.
4609
4610// CustomApp is one installed app's metadata for the admin list (the source is
4611// read separately — it can be large and the list never needs it).
4612type CustomApp struct {
4613 Name string `json:"name"`
4614 Version int `json:"version"`
4615 Size int `json:"size"`
4616 UpdatedAt int64 `json:"updated_at"`
4617 // OnDisk marks an LCHAT_APPS_DIR sideload in the admin list — never a DB
4618 // row; the console shows it read-only (see listCustomAppsHandler).
4619 OnDisk bool `json:"on_disk,omitempty"`
4620}
4621
4622// putCustomApp installs an app or replaces its source, bumping version on update
4623// so the admin list shows that a revision landed.
4624func (db *DB) putCustomApp(name, source string) error {
4625 now := time.Now().Unix()
4626 _, err := db.sql.Exec(
4627 "INSERT INTO custom_apps(name, source, enabled, version, created_at, updated_at) VALUES(?, ?, 1, 1, ?, ?) "+
4628 "ON CONFLICT(name) DO UPDATE SET source = excluded.source, enabled = 1, "+
4629 "version = custom_apps.version + 1, updated_at = excluded.updated_at",
4630 name, source, now, now,
4631 )
4632 return err
4633}
4634
4635// getCustomApp returns an installed, enabled app's source (ok=false if none).
4636func (db *DB) getCustomApp(name string) (string, bool, error) {
4637 var source string
4638 err := db.sql.QueryRow(
4639 "SELECT source FROM custom_apps WHERE name = ? AND enabled = 1", name,
4640 ).Scan(&source)
4641 if err == sql.ErrNoRows {
4642 return "", false, nil
4643 }
4644 if err != nil {
4645 return "", false, err
4646 }
4647 return source, true, nil
4648}
4649
4650// listCustomApps returns the installed apps' metadata, sorted by name.
4651func (db *DB) listCustomApps() ([]CustomApp, error) {
4652 rows, err := db.sql.Query(
4653 "SELECT name, version, length(source), updated_at FROM custom_apps ORDER BY name")
4654 if err != nil {
4655 return nil, err
4656 }
4657 defer rows.Close()
4658 out := []CustomApp{}
4659 for rows.Next() {
4660 var a CustomApp
4661 if err := rows.Scan(&a.Name, &a.Version, &a.Size, &a.UpdatedAt); err != nil {
4662 return nil, err
4663 }
4664 out = append(out, a)
4665 }
4666 return out, rows.Err()
4667}
4668
4669// deleteCustomApp uninstalls an app; reports whether it existed.
4670func (db *DB) deleteCustomApp(name string) (bool, error) {
4671 res, err := db.sql.Exec("DELETE FROM custom_apps WHERE name = ?", name)
4672 if err != nil {
4673 return false, err
4674 }
4675 n, _ := res.RowsAffected()
4676 return n > 0, nil
4677}
4678
4679// ---- member apps (memberapps.go) -----------------------------------------
4680//
4681// Member-built apps: one instance-key-encrypted { source, manifest } blob per
4682// app, keyed by a random id. The server can't open the blob; author_id is the
4683// deploying member (bots deploy on a requester's behalf under their own id).
4684
4685// MemberApp is one deployed member app as the member API returns it — the blob
4686// rides along because only clients can decrypt it; there is no metadata to
4687// list separately.
4688type MemberApp struct {
4689 ID string `json:"id"`
4690 Blob string `json:"blob,omitempty"`
4691 AuthorID string `json:"author_id"`
4692 OwnerID string `json:"owner_id"` // who it belongs to ("" for pre-ownership apps)
4693 Version int `json:"version"`
4694 UpdatedAt int64 `json:"updated_at"`
4695}
4696
4697// maxMemberAppVersions bounds the stored history per app — enough to revert a
4698// bad change, not so many that big blobs pile up unbounded.
4699const maxMemberAppVersions = 25
4700
4701// putMemberApp deploys or updates a member app and returns the resulting
4702// version. A fresh insert is author-owned (owner = author); an update keeps the
4703// original author_id AND owner_id (ownership never transfers via an overwrite)
4704// and bumps version. Each deploy records the blob in member_app_versions (the
4705// history the member can restore). basedOn > 0 marks a revert — the version
4706// this deploy's code came from.
4707func (db *DB) putMemberApp(id, blob, authorID string, basedOn int) (int, error) {
4708 tx, err := db.sql.Begin()
4709 if err != nil {
4710 return 0, err
4711 }
4712 defer tx.Rollback()
4713 now := time.Now().Unix()
4714 if _, err := tx.Exec(
4715 "INSERT INTO member_apps(id, blob, author_id, owner_id, version, created_at, updated_at) VALUES(?, ?, ?, ?, 1, ?, ?) "+
4716 "ON CONFLICT(id) DO UPDATE SET blob = excluded.blob, "+
4717 "version = member_apps.version + 1, updated_at = excluded.updated_at",
4718 id, blob, authorID, authorID, now, now,
4719 ); err != nil {
4720 return 0, err
4721 }
4722 var version int
4723 if err := tx.QueryRow("SELECT version FROM member_apps WHERE id = ?", id).Scan(&version); err != nil {
4724 return 0, err
4725 }
4726 if err := recordMemberAppVersion(tx, id, version, blob, authorID, now, basedOn); err != nil {
4727 return 0, err
4728 }
4729 return version, tx.Commit()
4730}
4731
4732// recordMemberAppVersion writes one version row (basedOn > 0 records the version
4733// a revert came from) and prunes the app's history to the most recent
4734// maxMemberAppVersions. Runs inside the deploy's transaction.
4735func recordMemberAppVersion(tx *sql.Tx, appID string, version int, blob, authorID string, now int64, basedOn int) error {
4736 var based any
4737 if basedOn > 0 {
4738 based = basedOn
4739 }
4740 if _, err := tx.Exec(
4741 "INSERT OR REPLACE INTO member_app_versions(app_id, version, blob, author_id, created_at, based_on) VALUES(?, ?, ?, ?, ?, ?)",
4742 appID, version, blob, authorID, now, based,
4743 ); err != nil {
4744 return err
4745 }
4746 _, err := tx.Exec("DELETE FROM member_app_versions WHERE app_id = ? AND version <= ?", appID, version-maxMemberAppVersions)
4747 return err
4748}
4749
4750// getMemberApp returns one member app (ok=false if none).
4751func (db *DB) getMemberApp(id string) (MemberApp, bool, error) {
4752 var a MemberApp
4753 err := db.sql.QueryRow(
4754 "SELECT id, blob, author_id, COALESCE(owner_id, ''), version, updated_at FROM member_apps WHERE id = ?", id,
4755 ).Scan(&a.ID, &a.Blob, &a.AuthorID, &a.OwnerID, &a.Version, &a.UpdatedAt)
4756 if err == sql.ErrNoRows {
4757 return MemberApp{}, false, nil
4758 }
4759 if err != nil {
4760 return MemberApp{}, false, err
4761 }
4762 return a, true, nil
4763}
4764
4765// listMemberApps returns every deployed member app, blobs included, oldest
4766// first so client registration order is stable across devices.
4767func (db *DB) listMemberApps() ([]MemberApp, error) {
4768 rows, err := db.sql.Query(
4769 "SELECT id, blob, author_id, COALESCE(owner_id, ''), version, updated_at FROM member_apps ORDER BY created_at, id")
4770 if err != nil {
4771 return nil, err
4772 }
4773 defer rows.Close()
4774 out := []MemberApp{}
4775 for rows.Next() {
4776 var a MemberApp
4777 if err := rows.Scan(&a.ID, &a.Blob, &a.AuthorID, &a.OwnerID, &a.Version, &a.UpdatedAt); err != nil {
4778 return nil, err
4779 }
4780 out = append(out, a)
4781 }
4782 return out, rows.Err()
4783}
4784
4785// ErrMemberAppLimit / ErrMemberAppAuthorLimit are the sentinel results of an
4786// over-cap createMemberApp — distinct so the handler can word each.
4787var (
4788 ErrMemberAppLimit = errors.New("member app limit reached")
4789 ErrMemberAppAuthorLimit = errors.New("per-author member app limit reached")
4790)
4791
4792// createMemberApp inserts a NEW member app, enforcing both the global and the
4793// per-owner caps INSIDE one transaction so concurrent deploys can't race past
4794// them (the count-then-insert was a TOCTOU hole otherwise). Returns
4795// ErrMemberAppLimit / ErrMemberAppAuthorLimit when a cap is hit. The cap is
4796// per-OWNER, so a member's apps count against them whether they deployed one
4797// themselves or a bot built it for them (rather than piling every member's
4798// bot-built apps onto the bot). Updates to an existing app go through
4799// putMemberApp, which is uncapped by design.
4800func (db *DB) createMemberApp(id, blob, authorID, ownerID string, globalCap, ownerCap int) error {
4801 tx, err := db.sql.Begin()
4802 if err != nil {
4803 return err
4804 }
4805 defer tx.Rollback()
4806 var total, mine int
4807 if err := tx.QueryRow("SELECT COUNT(*) FROM member_apps").Scan(&total); err != nil {
4808 return err
4809 }
4810 if total >= globalCap {
4811 return ErrMemberAppLimit
4812 }
4813 if err := tx.QueryRow("SELECT COUNT(*) FROM member_apps WHERE owner_id = ?", ownerID).Scan(&mine); err != nil {
4814 return err
4815 }
4816 if mine >= ownerCap {
4817 return ErrMemberAppAuthorLimit
4818 }
4819 now := time.Now().Unix()
4820 if _, err := tx.Exec(
4821 "INSERT INTO member_apps(id, blob, author_id, owner_id, version, created_at, updated_at) VALUES(?, ?, ?, ?, 1, ?, ?)",
4822 id, blob, authorID, ownerID, now, now,
4823 ); err != nil {
4824 return err
4825 }
4826 if err := recordMemberAppVersion(tx, id, 1, blob, authorID, now, 0); err != nil {
4827 return err
4828 }
4829 return tx.Commit()
4830}
4831
4832// countMemberApps returns the total deployed member apps (for tests + the
4833// cap; the enforcing count lives inside createMemberApp's transaction).
4834func (db *DB) countMemberApps() (int, error) {
4835 var n int
4836 err := db.sql.QueryRow("SELECT COUNT(*) FROM member_apps").Scan(&n)
4837 return n, err
4838}
4839
4840// deleteMemberApp removes a member app and its version history; reports whether
4841// it existed.
4842func (db *DB) deleteMemberApp(id string) (bool, error) {
4843 res, err := db.sql.Exec("DELETE FROM member_apps WHERE id = ?", id)
4844 if err != nil {
4845 return false, err
4846 }
4847 // Reclaim the history too (best-effort; the app row is the source of truth).
4848 _, _ = db.sql.Exec("DELETE FROM member_app_versions WHERE app_id = ?", id)
4849 n, _ := res.RowsAffected()
4850 return n > 0, nil
4851}
4852
4853// MemberAppVersion is one entry in an app's history, blob included (only clients
4854// can open it). Newest first. BasedOn is the version a revert came from (0 = not
4855// a revert).
4856type MemberAppVersion struct {
4857 Version int `json:"version"`
4858 Blob string `json:"blob"`
4859 AuthorID string `json:"author_id"`
4860 CreatedAt int64 `json:"created_at"`
4861 BasedOn int `json:"based_on,omitempty"`
4862}
4863
4864// backfillMemberAppVersions records each app's current version into the history
4865// for apps deployed before version tracking existed. Idempotent (INSERT OR IGNORE
4866// skips any already recorded); pre-feature intermediate versions are gone, but
4867// the current one is recovered from member_apps so History is never empty.
4868func (db *DB) backfillMemberAppVersions() error {
4869 _, err := db.sql.Exec(
4870 "INSERT OR IGNORE INTO member_app_versions(app_id, version, blob, author_id, created_at) " +
4871 "SELECT id, version, blob, author_id, updated_at FROM member_apps")
4872 return err
4873}
4874
4875// listMemberAppVersions returns an app's stored versions, newest first.
4876func (db *DB) listMemberAppVersions(appID string) ([]MemberAppVersion, error) {
4877 rows, err := db.sql.Query(
4878 "SELECT version, blob, author_id, created_at, COALESCE(based_on, 0) FROM member_app_versions WHERE app_id = ? ORDER BY version DESC", appID)
4879 if err != nil {
4880 return nil, err
4881 }
4882 defer rows.Close()
4883 out := []MemberAppVersion{}
4884 for rows.Next() {
4885 var v MemberAppVersion
4886 if err := rows.Scan(&v.Version, &v.Blob, &v.AuthorID, &v.CreatedAt, &v.BasedOn); err != nil {
4887 return nil, err
4888 }
4889 out = append(out, v)
4890 }
4891 return out, rows.Err()
4892}
4893
4894// ---- member bots ---------------------------------------------------------
4895//
4896// The charter store mirrors member_apps (same trust model, same ownership
4897// gate) minus version history: a charter is small and editable in
4898// conversation, so "ask the bot to change it back" is the history.
4899
4900// MemberBot is one stored charter as the member API returns it — the blob
4901// rides along because only clients (and the sidecar) can decrypt it.
4902type MemberBot struct {
4903 ID string `json:"id"`
4904 Blob string `json:"blob,omitempty"`
4905 AuthorID string `json:"author_id"`
4906 OwnerID string `json:"owner_id"`
4907 UpdatedAt int64 `json:"updated_at"`
4908}
4909
4910// ErrMemberBotLimit / ErrMemberBotOwnerLimit are the sentinel results of an
4911// over-cap createMemberBot — distinct so the handler can word each.
4912var (
4913 ErrMemberBotLimit = errors.New("member bot limit reached")
4914 ErrMemberBotOwnerLimit = errors.New("per-owner member bot limit reached")
4915)
4916
4917// createMemberBot inserts a NEW charter, enforcing the global and per-owner
4918// caps inside one transaction (the same TOCTOU-proof shape as createMemberApp).
4919func (db *DB) createMemberBot(id, blob, authorID, ownerID string, globalCap, ownerCap int) error {
4920 tx, err := db.sql.Begin()
4921 if err != nil {
4922 return err
4923 }
4924 defer tx.Rollback()
4925 var total, mine int
4926 if err := tx.QueryRow("SELECT COUNT(*) FROM member_bots").Scan(&total); err != nil {
4927 return err
4928 }
4929 if total >= globalCap {
4930 return ErrMemberBotLimit
4931 }
4932 if err := tx.QueryRow("SELECT COUNT(*) FROM member_bots WHERE owner_id = ?", ownerID).Scan(&mine); err != nil {
4933 return err
4934 }
4935 if mine >= ownerCap {
4936 return ErrMemberBotOwnerLimit
4937 }
4938 now := time.Now().Unix()
4939 if _, err := tx.Exec(
4940 "INSERT INTO member_bots(id, blob, author_id, owner_id, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?)",
4941 id, blob, authorID, ownerID, now, now,
4942 ); err != nil {
4943 return err
4944 }
4945 return tx.Commit()
4946}
4947
4948// putMemberBot overwrites an existing charter's blob. Author and owner stay
4949// as created (ownership never transfers via an overwrite).
4950func (db *DB) putMemberBot(id, blob string) error {
4951 _, err := db.sql.Exec(
4952 "UPDATE member_bots SET blob = ?, updated_at = ? WHERE id = ?",
4953 blob, time.Now().Unix(), id)
4954 return err
4955}
4956
4957// getMemberBot returns one stored charter (ok=false if none).
4958func (db *DB) getMemberBot(id string) (MemberBot, bool, error) {
4959 var b MemberBot
4960 err := db.sql.QueryRow(
4961 "SELECT id, blob, author_id, COALESCE(owner_id, ''), updated_at FROM member_bots WHERE id = ?", id,
4962 ).Scan(&b.ID, &b.Blob, &b.AuthorID, &b.OwnerID, &b.UpdatedAt)
4963 if err == sql.ErrNoRows {
4964 return MemberBot{}, false, nil
4965 }
4966 if err != nil {
4967 return MemberBot{}, false, err
4968 }
4969 return b, true, nil
4970}
4971
4972// listMemberBots returns every stored charter, blobs included, oldest first.
4973func (db *DB) listMemberBots() ([]MemberBot, error) {
4974 rows, err := db.sql.Query(
4975 "SELECT id, blob, author_id, COALESCE(owner_id, ''), updated_at FROM member_bots ORDER BY created_at, id")
4976 if err != nil {
4977 return nil, err
4978 }
4979 defer rows.Close()
4980 out := []MemberBot{}
4981 for rows.Next() {
4982 var b MemberBot
4983 if err := rows.Scan(&b.ID, &b.Blob, &b.AuthorID, &b.OwnerID, &b.UpdatedAt); err != nil {
4984 return nil, err
4985 }
4986 out = append(out, b)
4987 }
4988 return out, rows.Err()
4989}
4990
4991// deleteMemberBot removes a charter; reports whether it existed.
4992func (db *DB) deleteMemberBot(id string) (bool, error) {
4993 res, err := db.sql.Exec("DELETE FROM member_bots WHERE id = ?", id)
4994 if err != nil {
4995 return false, err
4996 }
4997 n, _ := res.RowsAffected()
4998 return n > 0, nil
4999}
5000
5001// ---- incoming webhooks (hooks.go, docs/webhooks.md) ----------------------
5002
5003// Webhook is one incoming-webhook row. The server can open neither blob: def
5004// is sealed to the hooks sidecar's service identity, meta to the instance key.
5005type Webhook struct {
5006 ID string `json:"id"`
5007 UserID string `json:"user_id"`
5008 DefBlob string `json:"def_blob,omitempty"`
5009 MetaBlob string `json:"meta_blob,omitempty"`
5010 DeployedAt int64 `json:"deployed_at"`
5011 CreatedAt int64 `json:"created_at"`
5012}
5013
5014func (db *DB) createWebhook(id, userID string) error {
5015 _, err := db.sql.Exec(
5016 "INSERT INTO webhooks(id, user_id, created_at) VALUES(?, ?, ?)",
5017 id, userID, time.Now().Unix())
5018 return err
5019}
5020
5021// setWebhookBlobs stores both encrypted blobs (the create flow's second step —
5022// the definition can only be sealed once the create response has minted the
5023// webhook's device token).
5024func (db *DB) setWebhookBlobs(id, defBlob, metaBlob string) (bool, error) {
5025 res, err := db.sql.Exec("UPDATE webhooks SET def_blob = ?, meta_blob = ? WHERE id = ?", defBlob, metaBlob, id)
5026 if err != nil {
5027 return false, err
5028 }
5029 n, _ := res.RowsAffected()
5030 return n > 0, nil
5031}
5032
5033func (db *DB) getWebhook(id string) (Webhook, bool, error) {
5034 var h Webhook
5035 err := db.sql.QueryRow(
5036 "SELECT id, user_id, def_blob, meta_blob, deployed_at, created_at FROM webhooks WHERE id = ?", id,
5037 ).Scan(&h.ID, &h.UserID, &h.DefBlob, &h.MetaBlob, &h.DeployedAt, &h.CreatedAt)
5038 if err == sql.ErrNoRows {
5039 return Webhook{}, false, nil
5040 }
5041 if err != nil {
5042 return Webhook{}, false, err
5043 }
5044 return h, true, nil
5045}
5046
5047// listWebhooks returns every webhook, oldest first, meta blobs included (the
5048// admin app's list view; def blobs stay out — the console never needs them).
5049func (db *DB) listWebhooks() ([]Webhook, error) {
5050 rows, err := db.sql.Query(
5051 "SELECT id, user_id, meta_blob, deployed_at, created_at FROM webhooks ORDER BY created_at, id")
5052 if err != nil {
5053 return nil, err
5054 }
5055 defer rows.Close()
5056 out := []Webhook{}
5057 for rows.Next() {
5058 var h Webhook
5059 if err := rows.Scan(&h.ID, &h.UserID, &h.MetaBlob, &h.DeployedAt, &h.CreatedAt); err != nil {
5060 return nil, err
5061 }
5062 out = append(out, h)
5063 }
5064 return out, rows.Err()
5065}
5066
5067// listWebhookDefs returns the rows the hooks sidecar polls: only webhooks whose
5068// definition has landed (the create flow's second step may still be in flight).
5069func (db *DB) listWebhookDefs() ([]Webhook, error) {
5070 rows, err := db.sql.Query(
5071 "SELECT id, user_id, def_blob, deployed_at, created_at FROM webhooks WHERE def_blob != '' ORDER BY created_at, id")
5072 if err != nil {
5073 return nil, err
5074 }
5075 defer rows.Close()
5076 out := []Webhook{}
5077 for rows.Next() {
5078 var h Webhook
5079 if err := rows.Scan(&h.ID, &h.UserID, &h.DefBlob, &h.DeployedAt, &h.CreatedAt); err != nil {
5080 return nil, err
5081 }
5082 out = append(out, h)
5083 }
5084 return out, rows.Err()
5085}
5086
5087// setWebhookDeployed records the sidecar's "route is live" ack (first ack wins;
5088// re-acks on later polls are no-ops so the timestamp stays the first go-live).
5089func (db *DB) setWebhookDeployed(id string) error {
5090 _, err := db.sql.Exec("UPDATE webhooks SET deployed_at = ? WHERE id = ? AND deployed_at = 0", time.Now().Unix(), id)
5091 return err
5092}
5093
5094func (db *DB) deleteWebhook(id string) (bool, error) {
5095 res, err := db.sql.Exec("DELETE FROM webhooks WHERE id = ?", id)
5096 if err != nil {
5097 return false, err
5098 }
5099 n, _ := res.RowsAffected()
5100 return n > 0, nil
5101}
5102
5103func (db *DB) countWebhooks() (int, error) {
5104 var n int
5105 err := db.sql.QueryRow("SELECT COUNT(*) FROM webhooks").Scan(&n)
5106 return n, err
5107}
5108
5109// ---- event journal + checkpoints (tamper evidence, checkpoint.go) --------
5110//
5111// The journal is append-only: rows are written inside the same transaction as
5112// the message write they describe, and nothing ever updates or deletes them.
5113// It stores ciphertext HASHES, never ciphertext — unsend still destroys the
5114// blob; what survives is evidence that an event happened, which is the whole
5115// point (docs/tamper-evidence.md).
5116
5117// JournalRow is one appended event. Field order and encoding are pinned by
5118// journalLeafHash (checkpoint.go) — they are wire format.
5119type JournalRow struct {
5120 Seq int64 `json:"seq"`
5121 TS int64 `json:"ts"`
5122 Kind string `json:"kind"`
5123 MsgID int64 `json:"msg_id"`
5124 ThreadID string `json:"thread_id"`
5125 BlobHash string `json:"blob_hash"`
5126}
5127
5128// ciphertextHash is the journal's commitment to a message blob: sha256 hex of
5129// the stored ciphertext text. Hashing the base64url string as stored (rather
5130// than decoded bytes) keeps verification a straight string comparison against
5131// what /api/messages serves. It happens to match hashToken today but is
5132// deliberately NOT shared with it: this is journal wire format — recorded
5133// hashes must stay verifiable forever — and must never follow a change to how
5134// tokens are hashed.
5135func ciphertextHash(blob string) string {
5136 sum := sha256.Sum256([]byte(blob))
5137 return hex.EncodeToString(sum[:])
5138}
5139
5140func appendJournalTx(tx *sql.Tx, ts int64, kind string, msgID int64, threadID, blobHash string) error {
5141 _, err := tx.Exec(
5142 "INSERT INTO event_journal(ts, kind, msg_id, thread_id, blob_hash) VALUES(?, ?, ?, ?, ?)",
5143 ts, kind, msgID, threadID, blobHash,
5144 )
5145 return err
5146}
5147
5148// journalMessageDeletesTx appends a 'delete' row for every message matching
5149// where — called immediately before the DELETE with the same predicate, in the
5150// same transaction, so the journal and the table can't disagree about what was
5151// removed. Bulk removals (thread teardown, member removal) journal one row per
5152// message on purpose: every vanished message row has exactly one explaining
5153// journal row, so a verifier never has to guess. One set-based statement, so
5154// purging a prolific member doesn't hold the single connection through N
5155// round-trips.
5156func journalMessageDeletesTx(tx *sql.Tx, where string, args ...any) error {
5157 _, err := tx.Exec(
5158 "INSERT INTO event_journal(ts, kind, msg_id, thread_id, blob_hash) "+
5159 "SELECT ?, 'delete', id, COALESCE(thread_id, ''), '' FROM messages WHERE "+where,
5160 append([]any{time.Now().Unix()}, args...)...,
5161 )
5162 return err
5163}
5164
5165// ensureCheckpointSeed loads (or mints) the checkpoint signing seed from the
5166// config table. In the DB on purpose: the seed must survive everything that
5167// moves instance state — hibernation wake, VACUUM-INTO migration, escrow
5168// restore — all of which carry only the DB. A key that silently changed on
5169// routine ops would make every legitimate restore read as forgery. The trade
5170// (a DB copy can sign as the instance) is accepted: the signature is
5171// attribution, not the security boundary (docs/tamper-evidence.md).
5172func (db *DB) ensureCheckpointSeed() (ed25519.PrivateKey, error) {
5173 enc, ok, err := db.getConfig("ckpt_seed")
5174 if err != nil {
5175 return nil, err
5176 }
5177 if ok && enc != "" {
5178 return checkpointSeedKey(enc, "config ckpt_seed")
5179 }
5180 _, priv, err := ed25519.GenerateKey(rand.Reader)
5181 if err != nil {
5182 return nil, err
5183 }
5184 if err := db.setConfig("ckpt_seed", base64.StdEncoding.EncodeToString(priv.Seed())); err != nil {
5185 return nil, err
5186 }
5187 return priv, nil
5188}
5189
5190// backfillEventJournal baselines messages that predate the journal: one 'msg'
5191// row per existing message, once (config-marked), in a single transaction.
5192// A backfilled row commits to the ciphertext as it stood at upgrade — evidence
5193// can't be retroactive (a pre-upgrade rewrite is invisible), but from this
5194// baseline forward every change to old messages is accounted for.
5195func (db *DB) backfillEventJournal() error {
5196 if v, _, _ := db.getConfig("journal_backfilled"); v == "1" {
5197 return nil
5198 }
5199 tx, err := db.sql.Begin()
5200 if err != nil {
5201 return err
5202 }
5203 defer tx.Rollback()
5204 // Buffer then insert: blob hashes are computed in Go (no sha256 in SQL),
5205 // and interleaving inserts with an open cursor on one connection is asking
5206 // for trouble. Fleet instances hold a few thousand messages at most.
5207 rows, err := tx.Query("SELECT id, created_at, COALESCE(thread_id, ''), blob FROM messages ORDER BY id")
5208 if err != nil {
5209 return err
5210 }
5211 var rs []JournalRow
5212 for rows.Next() {
5213 var r JournalRow
5214 var blob string
5215 if err := rows.Scan(&r.MsgID, &r.TS, &r.ThreadID, &blob); err != nil {
5216 rows.Close()
5217 return err
5218 }
5219 r.BlobHash = ciphertextHash(blob)
5220 rs = append(rs, r)
5221 }
5222 rows.Close()
5223 if err := rows.Err(); err != nil {
5224 return err
5225 }
5226 for _, r := range rs {
5227 if err := appendJournalTx(tx, r.TS, "msg", r.MsgID, r.ThreadID, r.BlobHash); err != nil {
5228 return err
5229 }
5230 }
5231 if err := setConfigTx(tx, "journal_backfilled", "1"); err != nil {
5232 return err
5233 }
5234 return tx.Commit()
5235}
5236
5237// journalAfter returns up to limit rows with seq > after, in order — the
5238// checkpoint sweep's read, and a verifier's replay feed.
5239func (db *DB) journalAfter(after int64, limit int) ([]JournalRow, error) {
5240 rows, err := db.sql.Query(
5241 "SELECT seq, ts, kind, msg_id, thread_id, blob_hash FROM event_journal WHERE seq > ? ORDER BY seq LIMIT ?",
5242 after, limit,
5243 )
5244 if err != nil {
5245 return nil, err
5246 }
5247 defer rows.Close()
5248 var out []JournalRow
5249 for rows.Next() {
5250 var r JournalRow
5251 if err := rows.Scan(&r.Seq, &r.TS, &r.Kind, &r.MsgID, &r.ThreadID, &r.BlobHash); err != nil {
5252 return nil, err
5253 }
5254 out = append(out, r)
5255 }
5256 return out, rows.Err()
5257}
5258
5259// Checkpoint is one signed link of the chain as served to verifiers.
5260type Checkpoint struct {
5261 ID int64 `json:"id"`
5262 SeqFrom int64 `json:"seq_from"`
5263 SeqTo int64 `json:"seq_to"`
5264 Root string `json:"root"`
5265 Chain string `json:"chain"`
5266 TS int64 `json:"ts"`
5267 Signed string `json:"signed"`
5268}
5269
5270func (db *DB) insertCheckpoint(p ckptPayload, signed string) error {
5271 _, err := db.sql.Exec(
5272 "INSERT INTO checkpoints(seq_from, seq_to, root, chain, ts, signed) VALUES(?, ?, ?, ?, ?, ?)",
5273 p.SeqFrom, p.SeqTo, p.Root, p.Chain, p.TS, signed,
5274 )
5275 return err
5276}
5277
5278func (db *DB) latestCheckpoint() (Checkpoint, bool, error) {
5279 var c Checkpoint
5280 err := db.sql.QueryRow(
5281 "SELECT id, seq_from, seq_to, root, chain, ts, signed FROM checkpoints ORDER BY id DESC LIMIT 1",
5282 ).Scan(&c.ID, &c.SeqFrom, &c.SeqTo, &c.Root, &c.Chain, &c.TS, &c.Signed)
5283 if err == sql.ErrNoRows {
5284 return Checkpoint{}, false, nil
5285 }
5286 if err != nil {
5287 return Checkpoint{}, false, err
5288 }
5289 return c, true, nil
5290}
5291
5292func (db *DB) checkpointsAfter(after int64, limit int) ([]Checkpoint, error) {
5293 rows, err := db.sql.Query(
5294 "SELECT id, seq_from, seq_to, root, chain, ts, signed FROM checkpoints WHERE id > ? ORDER BY id LIMIT ?",
5295 after, limit,
5296 )
5297 if err != nil {
5298 return nil, err
5299 }
5300 defer rows.Close()
5301 out := []Checkpoint{}
5302 for rows.Next() {
5303 var c Checkpoint
5304 if err := rows.Scan(&c.ID, &c.SeqFrom, &c.SeqTo, &c.Root, &c.Chain, &c.TS, &c.Signed); err != nil {
5305 return nil, err
5306 }
5307 out = append(out, c)
5308 }
5309 return out, rows.Err()
5310}
5311
5312// checkpointCovering returns the checkpoint whose range includes journal seq
5313// (seq_from <= seq <= seq_to) — the one an inclusion proof for that seq builds
5314// against. ok=false when seq is beyond the last checkpoint (not yet committed,
5315// which the client reads as "too recent", not tampering).
5316func (db *DB) checkpointCovering(seq int64) (Checkpoint, bool, error) {
5317 var c Checkpoint
5318 err := db.sql.QueryRow(
5319 "SELECT id, seq_from, seq_to, root, chain, ts, signed FROM checkpoints WHERE seq_from <= ? AND seq_to >= ? LIMIT 1",
5320 seq, seq,
5321 ).Scan(&c.ID, &c.SeqFrom, &c.SeqTo, &c.Root, &c.Chain, &c.TS, &c.Signed)
5322 if err == sql.ErrNoRows {
5323 return Checkpoint{}, false, nil
5324 }
5325 if err != nil {
5326 return Checkpoint{}, false, err
5327 }
5328 return c, true, nil
5329}
5330
5331// journalRange returns the journal rows with seq in [from, to] in order — the
5332// leaves of a checkpoint's Merkle tree, for building an audit path.
5333func (db *DB) journalRange(from, to int64) ([]JournalRow, error) {
5334 rows, err := db.sql.Query(
5335 "SELECT seq, ts, kind, msg_id, thread_id, blob_hash FROM event_journal WHERE seq >= ? AND seq <= ? ORDER BY seq",
5336 from, to,
5337 )
5338 if err != nil {
5339 return nil, err
5340 }
5341 defer rows.Close()
5342 var out []JournalRow
5343 for rows.Next() {
5344 var r JournalRow
5345 if err := rows.Scan(&r.Seq, &r.TS, &r.Kind, &r.MsgID, &r.ThreadID, &r.BlobHash); err != nil {
5346 return nil, err
5347 }
5348 out = append(out, r)
5349 }
5350 return out, rows.Err()
5351}
5352
5353// journalRowsForMsg returns every journal row for a message id (its insert,
5354// any edits, a delete) in order — a client sampling that message asks for its
5355// proofs and matches the one whose blob_hash equals its held ciphertext.
5356func (db *DB) journalRowsForMsg(msgID int64) ([]JournalRow, error) {
5357 rows, err := db.sql.Query(
5358 "SELECT seq, ts, kind, msg_id, thread_id, blob_hash FROM event_journal WHERE msg_id = ? ORDER BY seq",
5359 msgID,
5360 )
5361 if err != nil {
5362 return nil, err
5363 }
5364 defer rows.Close()
5365 var out []JournalRow
5366 for rows.Next() {
5367 var r JournalRow
5368 if err := rows.Scan(&r.Seq, &r.TS, &r.Kind, &r.MsgID, &r.ThreadID, &r.BlobHash); err != nil {
5369 return nil, err
5370 }
5371 out = append(out, r)
5372 }
5373 return out, rows.Err()
5374}
5375