| 1 | package main |
| 2 | |
| 3 | import ( |
| 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). |
| 24 | const 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. |
| 28 | func (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 | |
| 36 | func (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. |
| 44 | func (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. |
| 57 | func (db *DB) getAdminName() string { v, _, _ := db.getConfig("admin_name"); return v } |
| 58 | func (db *DB) getAdminEmail() string { v, _, _ := db.getConfig("admin_email"); return v } |
| 59 | |
| 60 | func (db *DB) setAdminName(v string) error { return db.setConfig("admin_name", v) } |
| 61 | func (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. |
| 68 | func (db *DB) getGroupDescription() string { |
| 69 | v, _, _ := db.getConfig("group_description") |
| 70 | return v |
| 71 | } |
| 72 | func (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. |
| 78 | func (db *DB) setupComplete() bool { |
| 79 | v, _, _ := db.getConfig("setup_complete") |
| 80 | return v == "1" |
| 81 | } |
| 82 | func (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. |
| 87 | func (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. |
| 100 | func (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). |
| 110 | func (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. |
| 118 | func (db *DB) usersCanInvite() bool { |
| 119 | v, ok, _ := db.getConfig("user_invites_enabled") |
| 120 | return ok && v == "1" |
| 121 | } |
| 122 | |
| 123 | func (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. |
| 136 | func (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. |
| 147 | func (db *DB) aiEnabledExplicitly() bool { |
| 148 | v, ok, _ := db.getConfig("ai_enabled") |
| 149 | return ok && v == "1" |
| 150 | } |
| 151 | func (db *DB) setAIEnabled(on bool) error { return db.setConfig("ai_enabled", boolStr(on)) } |
| 152 | |
| 153 | func (db *DB) botsEnabled() bool { |
| 154 | v, ok, _ := db.getConfig("bots_enabled") |
| 155 | return !ok || v == "1" |
| 156 | } |
| 157 | func (db *DB) setBotsEnabled(on bool) error { return db.setConfig("bots_enabled", boolStr(on)) } |
| 158 | |
| 159 | func (db *DB) appsEnabled() bool { |
| 160 | v, ok, _ := db.getConfig("apps_enabled") |
| 161 | return !ok || v == "1" |
| 162 | } |
| 163 | func (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. |
| 170 | func (db *DB) memberAppsEnabled() bool { |
| 171 | v, ok, _ := db.getConfig("member_apps_enabled") |
| 172 | return !ok || v == "1" |
| 173 | } |
| 174 | func (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. |
| 184 | func (db *DB) spaceInstallsAdmins() bool { |
| 185 | v, _, _ := db.getConfig("space_installs_admins") |
| 186 | return v == "1" |
| 187 | } |
| 188 | func (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. |
| 196 | func (db *DB) updateAuto() bool { |
| 197 | v, ok, _ := db.getConfig("update_auto") |
| 198 | return !ok || v == "1" |
| 199 | } |
| 200 | func (db *DB) setUpdateAuto(on bool) error { return db.setConfig("update_auto", boolStr(on)) } |
| 201 | |
| 202 | func (db *DB) updateDownload() bool { |
| 203 | v, ok, _ := db.getConfig("update_download") |
| 204 | return !ok || v == "1" |
| 205 | } |
| 206 | func (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. |
| 210 | func (db *DB) updateChannel() string { |
| 211 | v, _, _ := db.getConfig("update_channel") |
| 212 | if !validChannel(v) { |
| 213 | return "stable" |
| 214 | } |
| 215 | return v |
| 216 | } |
| 217 | func (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. |
| 224 | func (db *DB) updatePin() string { |
| 225 | v, _, _ := db.getConfig("update_pin") |
| 226 | if !validVersion(v) { |
| 227 | return "" |
| 228 | } |
| 229 | return v |
| 230 | } |
| 231 | func (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. |
| 238 | func (db *DB) updateMajor() string { |
| 239 | v, _, _ := db.getConfig("update_major") |
| 240 | if !validMajor(v) { |
| 241 | return "" |
| 242 | } |
| 243 | return v |
| 244 | } |
| 245 | func (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. |
| 254 | func (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 | |
| 263 | func (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. |
| 270 | type 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 | |
| 283 | func 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 | |
| 340 | func (db *DB) migrate() error { |
| 341 | _, err := db.sql.Exec(` |
| 342 | CREATE TABLE IF NOT EXISTS config ( |
| 343 | key TEXT PRIMARY KEY, |
| 344 | value TEXT NOT NULL |
| 345 | ); |
| 346 | CREATE TABLE IF NOT EXISTS messages ( |
| 347 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 348 | created_at INTEGER NOT NULL, |
| 349 | blob TEXT NOT NULL |
| 350 | ); |
| 351 | CREATE 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 | ); |
| 358 | CREATE 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). |
| 368 | CREATE 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 | ); |
| 375 | CREATE TABLE IF NOT EXISTS users ( |
| 376 | id TEXT PRIMARY KEY, |
| 377 | label TEXT NOT NULL DEFAULT '', |
| 378 | created_at INTEGER NOT NULL |
| 379 | ); |
| 380 | CREATE 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 | ); |
| 388 | CREATE 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. |
| 400 | CREATE 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 | ); |
| 405 | CREATE 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 | ); |
| 411 | CREATE 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 | ); |
| 417 | CREATE 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 | ); |
| 423 | CREATE 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. |
| 431 | CREATE 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). |
| 442 | CREATE 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). |
| 450 | CREATE 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. |
| 459 | CREATE 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. |
| 471 | CREATE 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. |
| 478 | CREATE 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. |
| 485 | CREATE 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. |
| 494 | CREATE 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. |
| 504 | CREATE 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. |
| 515 | CREATE 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. |
| 527 | CREATE 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. |
| 539 | CREATE 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 | ); |
| 550 | CREATE 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. |
| 560 | CREATE 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. |
| 573 | CREATE 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. |
| 582 | CREATE 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. |
| 593 | CREATE 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 | ); |
| 601 | CREATE 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. |
| 606 | CREATE 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. |
| 619 | CREATE 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. |
| 631 | CREATE 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. |
| 643 | CREATE 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. |
| 658 | CREATE 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. |
| 671 | CREATE 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). |
| 685 | CREATE 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. |
| 701 | CREATE 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. |
| 717 | CREATE 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. |
| 728 | CREATE 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. |
| 740 | CREATE 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. |
| 744 | CREATE 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. |
| 1008 | func (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. |
| 1017 | func (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). |
| 1062 | func (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 | |
| 1075 | func (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 | |
| 1103 | func (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 | |
| 1129 | func (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 | |
| 1141 | func (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. |
| 1150 | func 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 | |
| 1163 | func 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. |
| 1172 | func 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 | |
| 1179 | func 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). |
| 1190 | func (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 | |
| 1204 | func (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 | |
| 1212 | func (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 | |
| 1226 | type 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 | |
| 1248 | func (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. |
| 1261 | func (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 | |
| 1275 | func (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. |
| 1319 | func (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. |
| 1331 | func (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. |
| 1346 | func (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. |
| 1358 | func (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). |
| 1379 | func (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. |
| 1391 | func (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). |
| 1412 | func (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. |
| 1421 | func (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 | |
| 1427 | func (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. |
| 1433 | func (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. |
| 1440 | func (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. |
| 1464 | func (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 | |
| 1507 | type 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 | |
| 1519 | func (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. |
| 1543 | func (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. |
| 1552 | func (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). |
| 1592 | func (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). |
| 1667 | func (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). |
| 1677 | func (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. |
| 1697 | func (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 | |
| 1703 | func (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). |
| 1709 | func 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). |
| 1716 | func (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). |
| 1723 | func (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 | |
| 1728 | func (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. |
| 1735 | func (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. |
| 1741 | func (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). |
| 1753 | type 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 | |
| 1789 | func (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). |
| 1826 | func 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. |
| 1832 | func 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). |
| 1838 | func (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. |
| 1845 | func (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). |
| 1852 | func (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. |
| 1863 | func (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. |
| 2028 | func (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). |
| 2042 | func (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). |
| 2049 | func (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). |
| 2059 | func (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). |
| 2069 | type 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. |
| 2084 | func (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). |
| 2095 | func (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 | |
| 2103 | func (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). |
| 2118 | func (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). |
| 2130 | type 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). |
| 2138 | func (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. |
| 2154 | func (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. |
| 2169 | func (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. |
| 2192 | func (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 | |
| 2211 | func 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). |
| 2235 | func (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). |
| 2246 | func (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. |
| 2267 | func (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. |
| 2309 | func (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. |
| 2346 | func 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. |
| 2379 | func (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. |
| 2403 | func (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. |
| 2414 | type 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 | |
| 2451 | func (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. |
| 2466 | func (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 | |
| 2484 | func (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 | |
| 2489 | func (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). |
| 2496 | func (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 | |
| 2501 | func (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 | |
| 2507 | func (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). |
| 2519 | func (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. |
| 2536 | func (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). |
| 2544 | func (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(¢s, ¤cy, &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'). |
| 2556 | type 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). |
| 2571 | func (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 | |
| 2578 | func (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. |
| 2592 | func (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. |
| 2627 | func (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. |
| 2639 | func (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). |
| 2648 | func (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. |
| 2660 | func (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. |
| 2677 | func (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). |
| 2697 | func (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). |
| 2727 | func (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. |
| 2750 | func (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 | |
| 2757 | var 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). |
| 2763 | func (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 | |
| 2786 | func (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). |
| 2796 | func (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. |
| 2803 | func (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). |
| 2814 | func (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. |
| 2836 | func (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. |
| 2845 | func (db *DB) tierMemberIDs(tierID string) ([]string, error) { |
| 2846 | return db.idColumn("SELECT user_id FROM user_tiers WHERE tier_id = ?", tierID) |
| 2847 | } |
| 2848 | |
| 2849 | func (db *DB) tierHolderIDs(tierID string) ([]string, error) { |
| 2850 | return db.idColumn("SELECT user_id FROM tier_keys WHERE tier_id = ?", tierID) |
| 2851 | } |
| 2852 | |
| 2853 | func (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. |
| 2880 | func (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'. |
| 2937 | func (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'). |
| 2949 | func (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'). |
| 2956 | func (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 | |
| 2965 | type 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. |
| 3012 | type 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). |
| 3019 | func (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. |
| 3027 | func (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). |
| 3074 | func (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 | |
| 3090 | func (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. |
| 3107 | func (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. |
| 3113 | func (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). |
| 3123 | func (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). |
| 3147 | func (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. |
| 3161 | func (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. |
| 3215 | type 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. |
| 3234 | func (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. |
| 3263 | func (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. |
| 3274 | func (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. |
| 3284 | func 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. |
| 3293 | func (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). |
| 3307 | func (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 | |
| 3319 | var 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). |
| 3325 | func (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. |
| 3365 | func (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). |
| 3383 | func (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). |
| 3400 | func (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). |
| 3425 | func (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. |
| 3442 | func (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. |
| 3455 | func (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". |
| 3468 | func (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 | |
| 3474 | func (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. |
| 3481 | func (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 | |
| 3489 | func (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. |
| 3495 | func (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. |
| 3507 | type 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. |
| 3527 | func (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. |
| 3556 | func (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. |
| 3564 | func (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. |
| 3594 | func (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). |
| 3602 | func (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. |
| 3615 | type 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. |
| 3628 | func (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. |
| 3639 | func (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). |
| 3669 | func (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). |
| 3683 | func (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). |
| 3692 | func (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. |
| 3711 | func (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. |
| 3729 | func (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). |
| 3740 | func (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. |
| 3748 | func (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. |
| 3761 | func (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). |
| 3782 | func (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. |
| 3796 | func (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. |
| 3802 | func (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). |
| 3808 | func (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). |
| 3818 | func (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. |
| 3842 | func 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). |
| 3851 | func (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 | |
| 3864 | func (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. |
| 3872 | func (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. |
| 3888 | func (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. |
| 3970 | func (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 | |
| 4020 | type 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 | |
| 4031 | func (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. |
| 4067 | func (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.) |
| 4117 | func (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. |
| 4175 | func (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. |
| 4197 | func (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). |
| 4237 | func (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). |
| 4252 | func (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. |
| 4275 | func (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. |
| 4292 | func (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. |
| 4302 | func (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). |
| 4320 | func (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 | |
| 4354 | type 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 | |
| 4362 | func (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. |
| 4378 | func (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 | |
| 4390 | func (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. |
| 4397 | func (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). |
| 4418 | func (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). |
| 4429 | func (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. |
| 4436 | func (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. |
| 4461 | func (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). |
| 4503 | func (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. |
| 4522 | func (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. |
| 4556 | func (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). |
| 4566 | func (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). |
| 4581 | func (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. |
| 4587 | func (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). |
| 4612 | type 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. |
| 4624 | func (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). |
| 4636 | func (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. |
| 4651 | func (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. |
| 4670 | func (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. |
| 4688 | type 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. |
| 4699 | const 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. |
| 4707 | func (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. |
| 4735 | func 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). |
| 4751 | func (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. |
| 4767 | func (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. |
| 4787 | var ( |
| 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. |
| 4800 | func (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). |
| 4834 | func (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. |
| 4842 | func (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). |
| 4856 | type 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. |
| 4868 | func (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. |
| 4876 | func (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. |
| 4902 | type 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. |
| 4912 | var ( |
| 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). |
| 4919 | func (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). |
| 4950 | func (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). |
| 4958 | func (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. |
| 4973 | func (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. |
| 4992 | func (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. |
| 5005 | type 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 | |
| 5014 | func (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). |
| 5024 | func (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 | |
| 5033 | func (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). |
| 5049 | func (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). |
| 5069 | func (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). |
| 5089 | func (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 | |
| 5094 | func (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 | |
| 5103 | func (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. |
| 5119 | type 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. |
| 5135 | func ciphertextHash(blob string) string { |
| 5136 | sum := sha256.Sum256([]byte(blob)) |
| 5137 | return hex.EncodeToString(sum[:]) |
| 5138 | } |
| 5139 | |
| 5140 | func 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. |
| 5156 | func 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). |
| 5172 | func (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. |
| 5195 | func (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. |
| 5239 | func (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. |
| 5260 | type 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 | |
| 5270 | func (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 | |
| 5278 | func (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 | |
| 5292 | func (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). |
| 5316 | func (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. |
| 5333 | func (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. |
| 5356 | func (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 | |