package main import ( "context" "crypto/ed25519" "crypto/rand" "crypto/sha256" "database/sql" "embed" "encoding/base64" "encoding/hex" "encoding/json" "errors" "flag" "fmt" "html" "io" "io/fs" "log" "net" "net/http" "net/url" "os" "os/signal" "strconv" "strings" "sync" "syscall" "time" "unicode/utf8" "lchat/internal/apns" ) // defaultMaxUploadBytes caps an encrypted attachment upload when // LCHAT_MAX_UPLOAD_BYTES is unset: ~50 MB, enough for a short Signal-style video // clip plus AES-GCM overhead. The blob is opaque ciphertext — the server can't // tell a video from a document. const defaultMaxUploadBytes = 50 << 20 // defaultBlobQuotaBytes caps total stored blob bytes per instance when // LCHAT_BLOB_QUOTA_BYTES is unset — the disk backstop. Uploads past it are // refused (507). Blobs are GC'd on message/thread delete, so this bounds // worst-case media growth rather than being a hard forever-ceiling. const defaultBlobQuotaBytes = 2 << 30 // maxServerAvatarBytes caps the admin-set instance branding photo. It's tiny by // design (the admin console downscales it to ~400px first), so a small cap is // plenty and keeps this plaintext asset from being abused as bulk storage. const maxServerAvatarBytes = 1 << 20 //go:embed web/pages/*.html web/static/* var webFS embed.FS // The changelog ships inside the binary: one short, user-facing entry per file, // named -.md so filename order is announcement order. The deploy // announcer bot (agent/announcer.mjs) polls /api/changelog and posts entries it // hasn't seen — "what just shipped" needs no external service, no repo // credential, and no operator machine: it travels with the deploy itself. // //go:embed changelog/*.md var changelogFS embed.FS // The mini-app authoring guide also ships inside the binary, served as plain // markdown at /api/app-guide — so a member can point any LLM at that URL and // have it learn how to build an app for this instance, with no repo checkout. // The static checker the guide's delivery step points at rides along the same // way (GET /api/check-app.mjs), so an author without the repo can still run it. // //go:embed docs/building-apps.md docs/building-member-apps.md tools/check-app.mjs var appGuideFS embed.FS type Server struct { db *DB hub *Hub pages map[string][]byte changelog []ChangelogEntry version string // frontend build hash; clients reload when it changes (see frontendVersion) // Where the welcome page's "Get the app" button points (TestFlight / App // Store link). Empty hides the button — set LCHAT_APP_STORE_URL per instance. appStoreURL string // The home server that hosts passkey backup enrolment. The chat page opens // /enroll as a user-initiated popup to back this instance up (see // the enrolment cross-origin contract); set LCHAT_HOME_ORIGIN per deployment. homeOrigin string // The public-web fetch service this instance's clients should prefer for // unfurls (docs/fetch.md) — an origin like https://fetch.example, set via // LCHAT_FETCH_URL. Empty (the default, and the self-host default on // purpose) keeps unfurling on this instance's own /api/unfurl. fetchURL string // The cloud ledger's plan flag, mirrored into this instance's env by the // provisioner (LCHAT_ENTITLEMENT, "free"/"paid"/"demo"). Gates the space // menu's Upgrade entry (free) and the demo countdown band (demo); empty on // self-hosted/operator instances, which have no billing surface and show // nothing. Display only — a claim flips the ledger without restarting this // process, so the band verifies against home's live status before showing. entitlement string // Where this instance's webhook-ingest sidecar is reachable from outside // (LCHAT_HOOKS_BASE, e.g. "http://127.0.0.1:8090" in dev). Empty — the // production default — lets clients derive https://in.; set only // where that derivation is wrong (local dev, unusual fronting). Served as // ingest_base on GET /api/hooks/service; see hooks.go / docs/webhooks.md. hooksBase string vapidPub string vapidPriv string apns apnsSender // nil unless APNs (direct p8 or relay) is configured maxUpload int64 // per-upload attachment cap (LCHAT_MAX_UPLOAD_BYTES) blobQuota int64 // per-instance total blob-bytes ceiling (LCHAT_BLOB_QUOTA_BYTES) // blobMirrorKick nudges the blob mirror sweep after an upload // (blobmirror.go, #992). Nil unless the store is mirrored. blobMirrorKick chan struct{} // The usage-pipe relay (usage.go, #857): where sidecar usage reports go // and the per-space meter token that authenticates them. Pushed into the // env by the provisioner; empty = unmetered (self-hosted / BYO key). meterURL string // LCHAT_METER_URL meterToken string // LCHAT_METER_TOKEN // Directory of sideloaded mini-app modules served at /static/apps-custom/ // (LCHAT_APPS_DIR); empty disables the feature. See customapps.go. appsDir string // brakes are the content-blind moderation rate limits (ratelimit.go); // lazily nil-safe via s.brake() so tests constructing Server{} literals // keep working. brakes *modBrakes // The report-notification throttle (issue #578): one push per quiet // window however many reports land. In-memory on purpose — a restart at // worst re-notifies once, and the queue itself is the durable record. reportPushMu sync.Mutex lastReportPush time.Time // inflight coalesces concurrent unfurls of the same URL+language: one // fetch runs, the rest wait on its *previewFlight for the shared result. inflight sync.Map // active tracks the last time each device token reported being open + focused // (token -> unix seconds); activeUsers rolls that up per owner (user id -> unix // seconds) so a new message skips push to ALL of a person's devices when they're // reading on any one of them — not just the exact device they're on. active sync.Map activeUsers sync.Map // presence tracks open sockets + last broadcast state per user, so members // see who's online/away/offline (presence.go). presence presenceTracker // upd caches the last verified release-manifest fetch, so the admin console // can say whether the tracked channel has something newer (updatecheck.go). upd updateState // pay is the payment provider for paid access levels (subscriptions.go). // noPay unless Stripe Connect is configured — the feature is dormant then. pay PayProvider // subFold serializes subscription-event folds: the ordering decision // (read the watermark, then write the row) spans separate statements, // and webhooks arrive concurrently. subFold sync.Mutex // Tamper evidence (checkpoint.go): the per-instance checkpoint signing key. // Empty ckptKeyPath (the default) stores the seed in the DB config so it // rides restores; LCHAT_CHECKPOINT_KEY_FILE overrides to a file. ckptMu // guards lazy loading (success-only caching); ckptSweepMu serializes the // checkpoint sweep so the chain can't fork. ckptKeyPath string ckptMu sync.Mutex ckptKey ed25519.PrivateKey ckptSweepMu sync.Mutex } func main() { // Subcommands: `lchat router` runs the multi-instance front door and the // add-instance/instances/admin-link commands manage it; anything else (bare // `lchat` plus all existing flags) runs a single instance, so existing // invocations are unchanged. See docs/instance-router.md. if len(os.Args) > 1 { switch os.Args[1] { case "router": runRouter(os.Args[2:]) return case "bootstrap": runBootstrap(os.Args[2:]) return case "add-instance": runAddInstance(os.Args[2:]) return case "mesh-seed": runMeshSeed(os.Args[2:]) return case "deploy": runDeploy(os.Args[2:]) return case "add-service": runAddService(os.Args[2:]) return case "add-bot": runAddBot(os.Args[2:]) return case "bot": runBotCmd(os.Args[2:]) return case "agents": runAgentsCmd(os.Args[2:]) return case "add-hooks": runAddHooks(os.Args[2:]) return case "hooks": runHooksCmd(os.Args[2:]) return case "links": runLinksCmd(os.Args[2:]) return case "fetch": runFetchCmd(os.Args[2:]) return case "remove": runRemove(os.Args[2:]) return case "instances": runInstances(os.Args[2:]) return case "env": runEnvCmd(os.Args[2:]) return case "hibernate": runHibernate(os.Args[2:]) return case "canary": runCanary(os.Args[2:]) return case "reset": runReset(os.Args[2:]) return case "replication": runReplication(os.Args[2:]) return case "relocate": runRelocate(os.Args[2:]) return case "admin-link": runAdminLink(os.Args[2:]) return case "setup-invite": runSetupInvite(os.Args[2:]) return case "version": // The build id compiled in via -ldflags "-X main.buildVersion=". // `stage` reads it from a built binary; the running instance reports it // at /api/version so a deploy can confirm the swap actually took. fmt.Println(buildVersion) return case "stage": runStage(os.Args[2:]) return case "install": runInstall(os.Args[2:]) return case "rollback": runRollback(os.Args[2:]) return case "init-versions": runInitVersions(os.Args[2:]) return case "release": runRelease(os.Args[2:]) return case "update": runUpdate(os.Args[2:]) return case "controld": runControld(os.Args[2:]) return case "cloud": runCloud(os.Args[2:]) return case "try": runTryCmd(os.Args[2:]) return case "relay": runRelayCmd(os.Args[2:]) return case "tunnel": runTunnelCmd(os.Args[2:]) return } // No case matched. A flag falls through to the single-instance form // below (unchanged); a word does not — it is a typo, and starting a // server for it is how a stray `lchat help` ran for days on a live // box. See subcommands.go. checkSubcommand(os.Args[1]) } addr := flag.String("addr", envOr("ADDR", ":1747"), "listen address (env: ADDR)") socket := flag.String("socket", envOr("LCHAT_SOCKET", ""), "unix socket to serve on instead of -addr (env: LCHAT_SOCKET); how the router fronts an instance") dbPath := flag.String("db", envOr("LCHAT_DB", "lchat.db"), "path to SQLite database file (env: LCHAT_DB)") resetAdmin := flag.Bool("reset-admin", false, "generate a new admin token and print its link") resetDB := flag.Bool("reset-db", false, "delete and recreate the database") flag.Parse() if *resetDB { _ = os.Remove(*dbPath) log.Printf("database reset: %s deleted", *dbPath) } db, err := openDB(*dbPath) if err != nil { log.Fatalf("open db: %v", err) } // Admin token: LCHAT_ADMIN_TOKEN env wins; otherwise generate one on first run. // NB: LCHAT_-prefixed on purpose — some platforms (e.g. Miren) auto-provision // a secret for a bare "ADMIN_TOKEN" and silently override your value. // TrimSpace guards against env-injection paths that add surrounding whitespace. if tok, err := db.ensureAdminToken(strings.TrimSpace(os.Getenv("LCHAT_ADMIN_TOKEN"))); err != nil { log.Fatalf("admin token: %v", err) } else if tok != "" { printAdminLink(*addr, tok) } if *resetAdmin { tok, err := db.resetAdminToken() if err != nil { log.Fatalf("reset admin token: %v", err) } printAdminLink(*addr, tok) } if err := applySeededName(db); err != nil { log.Fatalf("seeded name: %v", err) } if err := applySkipSetup(db); err != nil { log.Fatalf("skip-setup defaults: %v", err) } pages, err := loadPages() if err != nil { log.Fatalf("load pages: %v", err) } hub := newHub() go hub.run() // TrimSpace guards against env-injection paths that add surrounding whitespace; // fall back to the shared home server when unset. homeOrigin := strings.TrimSpace(os.Getenv("LCHAT_HOME_ORIGIN")) if homeOrigin == "" { homeOrigin = defaultHomeOrigin } s := &Server{db: db, hub: hub, pages: pages, changelog: loadChangelog(), version: frontendVersion(), appStoreURL: strings.TrimSpace(os.Getenv("LCHAT_APP_STORE_URL")), homeOrigin: homeOrigin, fetchURL: strings.TrimRight(strings.TrimSpace(os.Getenv("LCHAT_FETCH_URL")), "/"), entitlement: strings.TrimSpace(os.Getenv("LCHAT_ENTITLEMENT")), maxUpload: envBytes("LCHAT_MAX_UPLOAD_BYTES", defaultMaxUploadBytes), blobQuota: envBytes("LCHAT_BLOB_QUOTA_BYTES", defaultBlobQuotaBytes), meterURL: strings.TrimSpace(os.Getenv("LCHAT_METER_URL")), meterToken: strings.TrimSpace(os.Getenv("LCHAT_METER_TOKEN")), hooksBase: strings.TrimRight(strings.TrimSpace(os.Getenv("LCHAT_HOOKS_BASE")), "/"), appsDir: strings.TrimSpace(os.Getenv("LCHAT_APPS_DIR"))} s.brakes = newModBrakes() // Tamper evidence (checkpoint.go): by default the signing seed lives in // the DB config so it survives hibernation wakes and migrations; set // LCHAT_CHECKPOINT_KEY_FILE to keep it in a file outside the DB instead. s.ckptKeyPath = strings.TrimSpace(os.Getenv("LCHAT_CHECKPOINT_KEY_FILE")) // Web Push: self-generated VAPID keypair (no third-party account). if pub, priv, err := db.ensureVAPID(); err != nil { log.Fatalf("vapid: %v", err) } else { s.vapidPub, s.vapidPriv = pub, priv } s.apns = newAPNsClient() // Paid access levels (subscriptions.go): the Stripe Connect provider, or // noPay (dormant) when unconfigured. The grace→revoke sweep only matters // once entitlements exist; harmless to always run. s.pay = newPayProvider(db) s.startBillingSweep() // Mini-app scheduling: deliver due "future messages" (miniapps.go). Tests // call sweepDueScheduled directly, so only the real server runs the ticker. go s.runScheduler() go s.runUpdateChecker() // Presence idle sweep (presence.go). Tests call sweepPresence directly. go s.runPresenceSweeper() // Blob mirror + audit sweeps (blobmirror.go, #992). No-op unless the // store is mirrored; tests call mirrorOnce/auditBlobsOnce directly. s.startBlobMirror() // Tamper evidence: fold new journal rows into signed checkpoints // (checkpoint.go). Tests call sweepCheckpoint directly. go s.runCheckpointer() // Push release notes new in THIS binary to the admins (announce.go). Tests // call announceChangelog directly. go s.announceChangelog() ln, where, err := listen(*addr, *socket) if err != nil { log.Fatalf("listen: %v", err) } log.Printf("lchat listening on %s (db: %s)", where, *dbPath) // ReadHeaderTimeout caps how long a client may dribble request headers // (Slowloris): standalone self-hosts bind this listener straight to public // TCP. Same 10s the router/cloud/controld listeners use. srv := &http.Server{Handler: s.handler(), ReadHeaderTimeout: 10 * time.Second} go func() { if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { log.Fatalf("serve: %v", err) } }() // Graceful shutdown on SIGTERM/SIGINT (systemd stop/restart during a deploy): // stop accepting + drain in-flight HTTP, then close WebSockets so clients // reconnect to the new process. The router retries dials across the brief gap, // so no request is dropped. See docs/instance-router.md. sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) <-sigCh log.Printf("lchat: draining and shutting down") // Drain in-flight HTTP (e.g. a large upload) before exiting. Kept below the // router's dial-retry window so the socket-absent gap during a deploy stays // within what the router masks. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = srv.Shutdown(ctx) hub.shutdown() } // listen returns the instance's listener. With -socket set it serves on a unix // domain socket (how the router fronts an instance — see docs/instance-router.md); // otherwise it's a TCP listener on addr (the standalone default, unchanged). A // stale socket left by a crashed process is removed first (but a socket another // process is still serving on refuses to start), and the socket is chmod 0600 so // only the router (same user) can dial it. func listen(addr, socketPath string) (net.Listener, string, error) { if socketPath == "" { ln, err := net.Listen("tcp", addr) return ln, addr, err } // Don't clobber a live socket: only a stale one (nothing listening) is removed. if c, err := net.DialTimeout("unix", socketPath, 200*time.Millisecond); err == nil { c.Close() return nil, "", fmt.Errorf("socket %s is already in use by another process", socketPath) } if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) { return nil, "", fmt.Errorf("remove stale socket %s: %w", socketPath, err) } ln, err := net.Listen("unix", socketPath) if err != nil { return nil, "", err } if err := os.Chmod(socketPath, 0o600); err != nil { ln.Close() return nil, "", fmt.Errorf("chmod socket %s: %w", socketPath, err) } return ln, "unix:" + socketPath, nil } // handler wires up all routes. It's a method so tests can exercise the full // router via httptest without starting a real listener. func (s *Server) handler() http.Handler { // The moderation brakes (ratelimit.go) exist before the first request — // handler() is the one door every construction path (main, tests) passes // through, so the lazy s.brake() fallback never races concurrent handlers. if s.brakes == nil { s.brakes = newModBrakes() } staticFS, err := fs.Sub(webFS, "web/static") if err != nil { log.Fatalf("static fs: %v", err) } mux := http.NewServeMux() // Pages mux.HandleFunc("GET /", s.servePage("index.html")) mux.HandleFunc("GET /admin", s.servePage("admin.html")) mux.HandleFunc("GET /join/{token}", s.servePage("join.html")) mux.Handle("GET /static/", noCache(http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))) // Custom (sideloaded) mini-apps. Deliberately one segment below /static/ so a // custom app's relative `import "../apps.js"` resolves to /static/apps.js — // same imports as a compiled-in app. See customapps.go for the trust model. mux.Handle("GET /static/apps-custom/{name}", noCache(http.HandlerFunc(s.serveCustomApp))) mux.HandleFunc("GET /api/apps", s.getCustomApps) // The integrations registry (public, like /api/apps): which services can // be connected and exactly what each op may do. See integrations_api.go. mux.HandleFunc("GET /api/integrations", s.getIntegrations) // Installing/removing custom apps is admin-only, like the rest of /api/admin. mux.HandleFunc("GET /api/admin/apps", s.listCustomAppsHandler) mux.HandleFunc("PUT /api/admin/apps/{name}", s.putCustomAppHandler) mux.HandleFunc("DELETE /api/admin/apps/{name}", s.deleteCustomAppHandler) // Member-built apps (memberapps.go): instance-key-encrypted blobs the client // runs only inside the sandbox frame below. Deploying is member-gated. mux.HandleFunc("GET /api/member-apps", s.listMemberAppsHandler) mux.HandleFunc("GET /api/member-apps/{id}/versions", s.memberAppVersionsHandler) mux.HandleFunc("PUT /api/member-apps/{id}", s.putMemberAppHandler) mux.HandleFunc("DELETE /api/member-apps/{id}", s.deleteMemberAppHandler) // Member bots (memberbots.go): opaque charter blobs, stored like member // apps; the sidecar decrypts and runs them (docs/member-bots.md). mux.HandleFunc("GET /api/member-bots", s.listMemberBotsHandler) mux.HandleFunc("PUT /api/member-bots/{id}", s.putMemberBotHandler) mux.HandleFunc("DELETE /api/member-bots/{id}", s.deleteMemberBotHandler) // The charter host's wake-time report (bot-token-gated; folded into // GET /api/next-due so hibernated charters still fire on time). mux.HandleFunc("POST /api/charter-next-due", s.postCharterNextDue) mux.HandleFunc("POST /api/bot-usage", s.postBotUsage) // Incoming webhooks (hooks.go, docs/webhooks.md): admin-managed registry of // opaque definition blobs the hooks sidecar polls; deployed is the sidecar's // plaintext go-live ack. mux.HandleFunc("GET /api/hooks/service", s.getHooksService) mux.HandleFunc("POST /api/hooks", s.createWebhookHandler) mux.HandleFunc("PUT /api/hooks/{id}/blobs", s.putWebhookBlobsHandler) mux.HandleFunc("GET /api/hooks", s.listWebhooksHandler) mux.HandleFunc("GET /api/hooks/defs", s.listWebhookDefsHandler) mux.HandleFunc("POST /api/hooks/{id}/deployed", s.webhookDeployedHandler) mux.HandleFunc("DELETE /api/hooks/{id}", s.deleteWebhookHandler) // The sandbox frame document: an opaque-origin cage for member-app code. mux.HandleFunc("GET /app-sandbox", s.serveAppSandbox) // Chat API (transport auth via invite token; never sees plaintext) mux.HandleFunc("GET /api/messages", s.getMessages) // Tamper evidence: the signed checkpoint chain over the event journal // (checkpoint.go; docs/tamper-evidence.md). Member-gated like messages. mux.HandleFunc("GET /api/checkpoints", s.getCheckpoints) // Merkle inclusion proof that a held message is committed to the chain // (checkpoint.go; docs/tamper-evidence.md client sampling). Member-gated. mux.HandleFunc("GET /api/checkpoint-proof", s.getCheckpointProof) mux.HandleFunc("POST /api/messages", s.postMessage) // Message permalinks ("Copy link", issue #893): mint an opaque, server- // sealed token for a message; resolve turns it back into ids only for a // caller who is currently a thread member (links.go). mux.HandleFunc("POST /api/messages/{id}/link", s.mintMessageLink) mux.HandleFunc("POST /api/resolve-link", s.resolveMessageLink) mux.HandleFunc("GET /ws", s.handleWS) // Threads & roster (see docs/threads-and-dms.md). The roster is the open // directory; threads (DMs/groups) carry server-blind, per-member wrapped keys. mux.HandleFunc("POST /api/pubkey", s.setPubkey) mux.HandleFunc("GET /api/roster", s.getRoster) mux.HandleFunc("GET /api/bot-config", s.getBotConfig) mux.HandleFunc("POST /api/bot-config", s.declareBotKind) mux.HandleFunc("GET /api/me", s.getMe) mux.HandleFunc("POST /api/device/signout", s.signout) mux.HandleFunc("POST /api/apns/subscribe", s.apnsSubscribe) mux.HandleFunc("POST /api/apns/unsubscribe", s.apnsUnsubscribe) // Instance key + profile photos: a shared key for all-members content (avatars). mux.HandleFunc("GET /api/instance-key", s.getInstanceKey) mux.HandleFunc("POST /api/instance-key", s.postInstanceKey) // The space thread — the hidden all-members thread backing space-wide apps // (spacethread.go, docs/space-apps.md). mux.HandleFunc("GET /api/space-thread", s.getSpaceThread) mux.HandleFunc("POST /api/space-thread", s.postSpaceThread) mux.HandleFunc("POST /api/admin/space-thread/reset", s.resetSpaceThreadHandler) mux.HandleFunc("POST /api/instance-key/nudge", s.nudgeInstanceKey) mux.HandleFunc("POST /api/admin/instance-key/reset", s.resetInstanceKeyHandler) // Tiers: admin-curated member sets with their own keys (docs/community-and-tiers.md). mux.HandleFunc("GET /api/tiers", s.getTiers) mux.HandleFunc("POST /api/tiers", s.postTier) mux.HandleFunc("DELETE /api/tiers/{id}", s.deleteTierHandler) mux.HandleFunc("POST /api/tiers/{id}/members", s.postTierMember) mux.HandleFunc("POST /api/tiers/{id}/remove", s.postTierRemove) mux.HandleFunc("POST /api/tiers/{id}/keys", s.postTierKeys) mux.HandleFunc("POST /api/tiers/{id}/nudge", s.nudgeTierKey) mux.HandleFunc("POST /api/tiers/{id}/policy", s.postTierPolicy) mux.HandleFunc("POST /api/tiers/{id}/show", s.postTierShow) // Paid access levels (subscriptions.go): admin price + account, member // subscribe/change/cancel/portal, and the provider webhook (public — its // signature is the auth). Dormant when no provider is configured. mux.HandleFunc("POST /api/tiers/{id}/price", s.postTierPrice) mux.HandleFunc("POST /api/tiers/{id}/subscribe", s.postTierSubscribe) mux.HandleFunc("POST /api/tiers/{id}/change", s.postTierChange) mux.HandleFunc("POST /api/tiers/{id}/cancel-subscription", s.postTierCancelSub) mux.HandleFunc("POST /api/tiers/{id}/resume", s.postTierResume) mux.HandleFunc("POST /api/tiers/{id}/portal", s.postTierPortal) mux.HandleFunc("POST /api/admin/billing/account", s.postBillingAccount) mux.HandleFunc("GET /api/admin/billing", s.getBillingStatus) mux.HandleFunc("POST /api/billing/webhook", s.stripeConnectWebhook) mux.HandleFunc("POST /api/label", s.postLabel) mux.HandleFunc("POST /api/label/backfill", s.postLabelBackfill) mux.HandleFunc("POST /api/presence", s.postPresence) mux.HandleFunc("POST /api/avatar", s.postAvatar) mux.HandleFunc("POST /api/description", s.postDescription) mux.HandleFunc("GET /api/nicknames", s.getNicknames) mux.HandleFunc("POST /api/nicknames", s.postNicknames) mux.HandleFunc("GET /api/prefs", s.getPrefs) mux.HandleFunc("POST /api/prefs", s.postPrefs) mux.HandleFunc("POST /api/dm-privacy", s.postDMPrivacy) mux.HandleFunc("GET /api/blocks", s.getBlocks) mux.HandleFunc("POST /api/blocks", s.postBlock) mux.HandleFunc("DELETE /api/blocks/{id}", s.deleteBlock) mux.HandleFunc("POST /api/reports", s.postReport) mux.HandleFunc("GET /api/admin/reports", s.adminReports) mux.HandleFunc("POST /api/admin/reports/{id}/resolve", s.resolveReportHandler) mux.HandleFunc("POST /api/admin/warn", s.adminWarn) mux.HandleFunc("GET /api/admin/audit", s.adminAudit) mux.HandleFunc("POST /api/admin/users/{id}/ban", s.banUser) mux.HandleFunc("POST /api/group-description", s.postGroupDescription) mux.HandleFunc("GET /api/threads", s.getThreads) mux.HandleFunc("POST /api/threads", s.postThread) mux.HandleFunc("GET /api/threads/open", s.getOpenThreads) mux.HandleFunc("POST /api/threads/{id}/join", s.postThreadJoin) mux.HandleFunc("POST /api/threads/{id}/visibility", s.postThreadVisibility) mux.HandleFunc("POST /api/threads/{id}/default", s.postThreadDefault) mux.HandleFunc("POST /api/threads/{id}/members", s.postThreadMember) mux.HandleFunc("POST /api/threads/{id}/name", s.postThreadName) mux.HandleFunc("POST /api/threads/{id}/avatar", s.postThreadAvatar) mux.HandleFunc("POST /api/threads/{id}/leave", s.postThreadLeave) mux.HandleFunc("POST /api/threads/{id}/cancel-invite", s.cancelInvite) mux.HandleFunc("POST /api/threads/{id}/remove", s.postThreadRemove) mux.HandleFunc("POST /api/threads/{id}/hidden", s.postThreadHidden) mux.HandleFunc("POST /api/threads/{id}/pinned", s.postThreadPinned) mux.HandleFunc("POST /api/threads/{id}/muted", s.postThreadMuted) mux.HandleFunc("POST /api/threads/{id}/close-dm", s.postCloseDM) mux.HandleFunc("POST /api/threads/{id}/reopen-dm", s.postReopenDM) mux.HandleFunc("POST /api/threads/{id}/read", s.postThreadRead) // Mini-apps (miniapps.go): scheduled "future messages" + per-thread state // snapshots. Both carry only client-encrypted blobs; both are member-gated. mux.HandleFunc("POST /api/schedule", s.postSchedule) mux.HandleFunc("GET /api/schedule", s.getSchedules) mux.HandleFunc("DELETE /api/schedule/{id}", s.deleteSchedule) // The activator's due-time probe (admin-gated; queried over the instance // socket at sleep time so hibernated schedules still fire on time). mux.HandleFunc("GET /api/next-due", s.getNextDue) mux.HandleFunc("GET /api/threads/{id}/state", s.getThreadStateHandler) mux.HandleFunc("PUT /api/threads/{id}/state", s.putThreadStateHandler) // Connected third-party services (docs/connected-services.md): opaque, // client-encrypted credential blobs keyed per member. Token-gated; the server // never reads inside. mux.HandleFunc("GET /api/connected", s.listConnected) mux.HandleFunc("GET /api/connected/{service}", s.getConnected) mux.HandleFunc("PUT /api/connected/{service}", s.putConnected) mux.HandleFunc("DELETE /api/connected/{service}", s.deleteConnected) // OAuth connect (docs/connected-services.md): public config for the browser to // start a PKCE flow, and the redirect landing that relays the code back. mux.HandleFunc("GET /api/oauth/config", s.oauthConfig) mux.HandleFunc("GET /oauth/{service}/callback", s.oauthCallbackPage) // Encrypted attachments: bytes are E2E-encrypted in the browser; the server // stores/serves opaque blobs only. Both ends are token-gated. mux.HandleFunc("POST /api/upload", s.uploadBlob) mux.HandleFunc("GET /api/blob/{id}", s.getBlobHandler) // Link previews: the server fetches a posted URL to build an OpenGraph card, // caches it, and serves cached results immediately (refreshing stale ones). mux.HandleFunc("GET /api/unfurl", s.unfurl) // Key-invites (invite redesign): server stores a client-wrapped key it can't // open; claiming mints a device token. See docs/invite-redesign.md. mux.HandleFunc("GET /api/key-invite/{id}", s.getKeyInvite) mux.HandleFunc("POST /api/key-invite/{id}/claim", s.claimKeyInvite) mux.HandleFunc("POST /api/key-invite/{id}/adopt", s.adoptKeyInvite) mux.HandleFunc("POST /api/admin/key-invites", s.createKeyInvite) mux.HandleFunc("GET /api/admin/tiers", s.adminTiers) mux.HandleFunc("GET /api/admin/users", s.listUsers) mux.HandleFunc("POST /api/admin/users/{id}/remove", s.removeUser) mux.HandleFunc("POST /api/admin/users/{id}/label", s.renameUser) mux.HandleFunc("GET /api/admin/bots/{id}/config", s.getAdminBotConfig) mux.HandleFunc("POST /api/admin/bots/{id}/config", s.setAdminBotConfig) // A member can mint a fresh invite for their own account (add a device). mux.HandleFunc("POST /api/device-invites", s.createDeviceInvite) // A member can invite a new person straight into a DM (gated by an admin setting). mux.HandleFunc("POST /api/invites", s.postUserInvite) // Group name (public read so the page/manifest can show it; admin write) mux.HandleFunc("GET /api/group", s.getGroup) // Server avatar: per-instance branding photo. Public read (shown in onboarding, // the home Save-passkey card, and native add-chat — all before any key exists); // admin write/clear. Served in the clear like the group name (see getServerAvatar). mux.HandleFunc("GET /api/server-avatar", s.getServerAvatar) mux.HandleFunc("POST /api/admin/server-avatar", s.setServerAvatar) mux.HandleFunc("DELETE /api/admin/server-avatar", s.deleteServerAvatar) // Frontend build version — clients poll this on returning to the app and reload // if it changed (auto-update). No auth: it's just a build hash. mux.HandleFunc("GET /api/version", s.getVersion) mux.HandleFunc("GET /api/update-prefs", s.getUpdatePrefs) mux.HandleFunc("GET /api/changelog", s.getChangelog) // The mini-app authoring guide. Static documentation compiled into the // binary — never instance data — so, like /api/version, it isn't token-gated: // the whole point is that an external LLM can be pointed straight at it. mux.HandleFunc("GET /api/app-guide", s.getAppGuide) mux.HandleFunc("GET /api/member-app-guide", s.getMemberAppGuide) mux.HandleFunc("GET /api/check-app.mjs", s.getAppChecker) mux.HandleFunc("POST /api/admin/group", s.setGroup) // Sealed branding (docs/no-plaintext.md): the client-sealed branding set // and its reversal. Admin-gated; the ciphertext is produced client-side. mux.HandleFunc("POST /api/admin/branding/seal", s.sealBranding) mux.HandleFunc("POST /api/admin/branding/unseal", s.unsealBranding) // Admin API (requires admin token) mux.HandleFunc("GET /api/admin/settings", s.getSettings) mux.HandleFunc("POST /api/admin/settings", s.setSettings) // Web Push (VAPID self-generated; pushed payload is ciphertext, decrypted in the SW) mux.HandleFunc("GET /api/push/vapid", s.getVAPID) mux.HandleFunc("POST /api/push/subscribe", s.subscribePush) mux.HandleFunc("POST /api/push/unsubscribe", s.unsubscribePush) mux.HandleFunc("POST /api/admin/test-push", s.testPush) // PWA: the service worker must be served from the root scope; manifest for install mux.HandleFunc("GET /sw.js", s.serveSW) mux.HandleFunc("GET /manifest.webmanifest", s.serveManifest) // Universal links: lets invite links on this instance's domain open straight // into the iOS app when it's installed (every instance is its own domain, and // every instance is this same binary, so each serves its own association file). mux.HandleFunc("GET /.well-known/apple-app-site-association", serveAASA) return securityHeaders(mux) } // securityHeaders adds defense-in-depth headers to every response. The page // Content-Security-Policy needs a per-request nonce, so it's set in servePage; // these are the static ones that apply everywhere (including the blob download, // where nosniff stops a mistyped blob being sniffed as HTML). func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Frame-Options", "DENY") h.Set("Referrer-Policy", "no-referrer") // Ignored by browsers over plain HTTP (e.g. local dev); applies in prod // where every instance is served over TLS. h.Set("Strict-Transport-Security", "max-age=31536000") next.ServeHTTP(w, r) }) } // cspNonce returns a fresh per-response nonce for the pages' inline