package main // Mini-app server surface (authoring docs: web/static/apps/README.md). // // Two primitives, both E2E-preserving — the server never sees inside a blob: // // Scheduling POST/GET /api/schedule, DELETE /api/schedule/{id} // A "future message": client-encrypted ciphertext held until deliver_at, // then moved into the thread's message log and fanned out (WS + push) // exactly like a posted message. Offline clients catch up from the log; // the service worker decrypts the push and can show an app-authored // notification. The server learns only (thread, time). // // State GET/PUT /api/threads/{id}/state // One encrypted snapshot blob per (thread, slot) with optimistic // versioning. Lets an app's folded state survive beyond the message // window (the >100-events problem) and hold things like credentials — // all ciphertext under the thread key, same trust boundary as messages. import ( "encoding/json" "log" "net/http" "time" ) // maxStateBytes caps a state snapshot blob. Larger than a message (a snapshot // aggregates many events) but still bounded so a misbehaving client can't grow // the DB without limit. const maxStateBytes = 256 * 1024 // maxPendingSchedules bounds the queue per thread — far above real use, low // enough that a runaway client can't flood the table. const maxPendingSchedules = 1000 // scheduleHorizon is how far ahead a delivery may be set. Two years is beyond // any plausible reminder; anything longer is almost certainly a client bug. const scheduleHorizon = 2 * 365 * 24 * time.Hour // memberOfThread resolves the caller's user id and checks thread membership — // the standard gate on every mini-app endpoint (mirrors getMessages). func (s *Server) memberOfThread(token, threadID string) (string, bool) { if threadID == "" || !s.tokenOK(token) { return "", false } userID, _ := s.db.deviceTokenUser(token) if userID == "" || !s.db.threadMember(threadID, userID) { return "", false } return userID, true } // postSchedule queues a future message: {token, thread_id, deliver_at, blob}. // The blob is already encrypted by the caller (the server can't check, only // bound it). Returns {id} for later cancellation. func (s *Server) postSchedule(w http.ResponseWriter, r *http.Request) { var in struct { Token string `json:"token"` ThreadID string `json:"thread_id"` DeliverAt int64 `json:"deliver_at"` Blob string `json:"blob"` } r.Body = http.MaxBytesReader(w, r.Body, maxBlobBytes+4096) if json.NewDecoder(r.Body).Decode(&in) != nil { httpError(w, http.StatusBadRequest, "bad json") return } userID, ok := s.memberOfThread(in.Token, in.ThreadID) if !ok { httpError(w, http.StatusForbidden, "not a thread member") return } if in.Blob == "" || len(in.Blob) > maxBlobBytes { httpError(w, http.StatusBadRequest, "bad blob") return } now := time.Now() // A small grace behind now absorbs clock skew; the sweep delivers it at once. if in.DeliverAt < now.Add(-time.Minute).Unix() || in.DeliverAt > now.Add(scheduleHorizon).Unix() { httpError(w, http.StatusBadRequest, "deliver_at out of range") return } if s.db.countScheduledMessages(in.ThreadID) >= maxPendingSchedules { httpError(w, http.StatusTooManyRequests, "too many pending schedules") return } id, err := s.db.createScheduledMessage(in.ThreadID, in.Blob, userID, in.DeliverAt) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } writeJSON(w, http.StatusOK, map[string]any{"id": id, "deliver_at": in.DeliverAt}) } // getSchedules lists a thread's pending schedules ({id, deliver_at, blob}) so a // client can reconcile app items with their schedule ids (e.g. a fresh device). func (s *Server) getSchedules(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() if _, ok := s.memberOfThread(q.Get("token"), q.Get("thread")); !ok { httpError(w, http.StatusForbidden, "not a thread member") return } list, err := s.db.listScheduledMessages(q.Get("thread")) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } writeJSON(w, http.StatusOK, map[string]any{"schedules": list}) } // deleteSchedule cancels a pending schedule. Any member of the schedule's thread // may cancel (thread members share the app's state, so they share its timers). // Cancelling an id that already fired (or never existed) succeeds quietly — // cancellation races delivery by design, and "already gone" is not an error. func (s *Server) deleteSchedule(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") id := r.PathValue("id") tid, found, err := s.db.scheduledMessageThread(id) if err != nil { // A real DB failure must not masquerade as "already cancelled": a false // 200 would let the client drop the reminder while it still fires. httpError(w, http.StatusInternalServerError, "db error") return } if found { if _, ok := s.memberOfThread(token, tid); !ok { httpError(w, http.StatusForbidden, "not a thread member") return } if err := s.db.deleteScheduledMessage(id); err != nil { httpError(w, http.StatusInternalServerError, "db error") return } } else if !s.tokenOK(token) { // Unknown id still requires a valid token — don't leak queue existence // to strangers. httpError(w, http.StatusForbidden, "invalid invite") return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // getNextDue reports the instance's earliest pending scheduled delivery: // {due: }, 0 when nothing is scheduled. It exists for the // router's activator: at sleep time it records this so a hibernating instance // is woken when the delivery comes due (activator.go's due-time index). // Admin-gated — the payload is a single timestamp the server already stores in // the clear (the schedule's (thread, time) metadata), but every data endpoint // carries auth, and the activator already holds the instance's admin token via // the env file it spawns instances with. // // The answer folds in the sidecar-reported charter due-time (memberbots.go // postCharterNextDue), so member-bot charters — whose schedules live in // blobs only the sidecar can read — wake a sleeping instance too. func (s *Server) getNextDue(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } due, err := s.db.nextScheduledDeliverAt() if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } writeJSON(w, http.StatusOK, map[string]any{ "due": combineNextDue(due, s.db.charterNextDue(), time.Now().Unix()), }) } // combineNextDue folds the charter host's reported due-time into the schedule // queue's earliest deliver_at: the sooner of the two wins, with 0 meaning // "nothing" on either side. // // The asymmetry is deliberate: a PAST schedule deliver_at is honored (delivery // consumes the row, so a past value only exists until the instance's next // scheduler sweep — waking for it is exactly right), but a PAST charter // due-time is ignored. The charter value has no consumption mechanism on the // server — it changes only when the sidecar re-reports — and this probe runs // at sleep time, after a full awake window. A value still in the past here // means the sidecar had that whole window to act on it and re-report, and // didn't (dead, keyless, or gone): honoring it would re-wake the instance // immediately after every sleep, forever. Ignoring it caps the damage at the // one wake the report legitimately bought and degrades to pre-index behavior // (sleep until a request) — which is also CORRECT, not just safe: charters run // in the sidecar, so waking an instance whose sidecar can't come up buys their // schedules nothing. No clearing is needed — an ignored past value is inert, // and any live sidecar overwrites it within one reconcile. (Same no-ping-pong // reasoning as the activator's due-time index: wake/sleep cycles can never be // spaced closer than the idle window, and a stale value never re-fires.) func combineNextDue(schedDue, charterDue, now int64) int64 { if charterDue <= 0 || charterDue < now { return schedDue } if schedDue == 0 || charterDue < schedDue { return charterDue } return schedDue } // getThreadStateHandler returns a slot's snapshot: {blob, version}. version 0 + // empty blob means nothing saved yet (the client starts a fresh fold). func (s *Server) getThreadStateHandler(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() threadID := r.PathValue("id") if _, ok := s.memberOfThread(q.Get("token"), threadID); !ok { httpError(w, http.StatusForbidden, "not a thread member") return } slot := q.Get("slot") if slot == "" { httpError(w, http.StatusBadRequest, "missing slot") return } blob, version, _, err := s.db.getThreadState(threadID, slot) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } writeJSON(w, http.StatusOK, map[string]any{"blob": blob, "version": version}) } // putThreadStateHandler writes a slot's snapshot with optimistic concurrency: // {token, slot, blob, version} where version is what the caller last read (0 to // create). A stale version gets 409 — reload, refold, retry (folds converge, so // the retry is always safe). func (s *Server) putThreadStateHandler(w http.ResponseWriter, r *http.Request) { var in struct { Token string `json:"token"` Slot string `json:"slot"` Blob string `json:"blob"` Version int64 `json:"version"` } r.Body = http.MaxBytesReader(w, r.Body, maxStateBytes+4096) if json.NewDecoder(r.Body).Decode(&in) != nil { httpError(w, http.StatusBadRequest, "bad json") return } threadID := r.PathValue("id") if _, ok := s.memberOfThread(in.Token, threadID); !ok { httpError(w, http.StatusForbidden, "not a thread member") return } if in.Slot == "" || in.Blob == "" || len(in.Blob) > maxStateBytes { httpError(w, http.StatusBadRequest, "bad slot or blob") return } version, ok, err := s.db.putThreadState(threadID, in.Slot, in.Blob, in.Version) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } if !ok { httpError(w, http.StatusConflict, "version conflict") return } writeJSON(w, http.StatusOK, map[string]any{"version": version}) } // ---- delivery ------------------------------------------------------------ // runScheduler sweeps due schedules forever. Coarse (a few seconds) is plenty: // reminders are minute-granularity, and each idle sweep is one indexed SELECT. func (s *Server) runScheduler() { for range time.Tick(5 * time.Second) { s.sweepDueScheduled() } } // sweepDueScheduled delivers every due schedule: move it into the message log // (transactional — see deliverScheduledMessage), then broadcast the standard // "msg" frame and push. A delivered schedule is indistinguishable from a posted // message, which is exactly what the client's fold model expects. Returns how // many were delivered (used by tests, which call this directly — no ticker). func (s *Server) sweepDueScheduled() int { due, err := s.db.dueScheduledMessages(time.Now().Unix(), 100) if err != nil { log.Println("schedule: sweep:", err) return 0 } n := 0 for _, sm := range due { // A DM closed since this was scheduled (issue #578): the conversation // ended, so the queued message dies with it rather than forcing the // thread back open. if closed, _ := s.db.dmClosed(sm.ThreadID); closed { _ = s.db.deleteScheduledMessage(sm.ID) continue } m, ok, err := s.db.deliverScheduledMessage(sm) if err != nil { log.Println("schedule: deliver:", err) continue } if !ok { continue // raced another sweep; that one broadcasts } n++ out, _ := json.Marshal(wsOut{Type: "msg", ThreadID: sm.ThreadID, ID: m.ID, CreatedAt: m.CreatedAt, Blob: m.Blob}) s.broadcastToThread(sm.ThreadID, out) go s.pushScheduled(m.Blob, sm.ThreadID, sm.AuthorID, m.ID, m.CreatedAt) } return n } // pushScheduled is pushNewMessage for a scheduled delivery, with one deliberate // difference: NO sender exclusion. The creator is exactly who wants this push (a // reminder that skipped its own author would be useless). skipActive stays on — // an open client receives the WS frame and the app's notify hook handles it. func (s *Server) pushScheduled(blob, threadID, authorID string, id, at int64) { if s.db.threadKind(threadID) == "space" { return // the space thread never pushes — see pushNewMessage } members, err := s.db.memberUserIDs(threadID) if err != nil { log.Println("schedule: push members:", err) return } set := make(map[string]bool, len(members)) for _, u := range members { set[u] = true } s.dropMutedRecipients(threadID, set) // mute wins even over a reminder if authorID != "" { s.dropBlockingRecipients(authorID, set) // a blocked author never buzzes the blocker } payload, _ := json.Marshal(map[string]string{"blob": blob, "thread": threadID}) s.sendToRecipients(payload, "sched", "", true, set) s.apnsFanOut(threadID, blob, id, at, "", true, set) }