Files
ae14d11ac3 fix(webapp): three readers on one network are three share opens (BEA-151) (#185)
The share actor key was token+"/"+IP, so every browser behind one NAT was
the same reader and the 10-minute visit debounce folded a whole office into
a single open — three personas each measured "1 open" for three readers, and
the panel's own copy promised the opposite.

The key gains a truncated hash of the User-Agent. ShareOpens already sums
across actor buckets and takes the max Last, so opens: 3 and an advancing
last_opened fall out with no aggregator change, no new field, and no change
to readDebounce. The UA is hashed because Record persists the actor through
ReadRepo into storage; token+"/"+IP stays the prefix so the existing leak
assertions keep covering the wider key.

The copy now states the rule the code implements, including its residual:
two people on one network in the same browser still count as one.

Deviation from the plan, deliberate: TestSec_Share_VisitorCannotInflateOrRedirectTheLedger
pinned "a visitor cannot split its own visits by varying the User-Agent".
That is now intended behavior, so the two UA rows move out of the
must-collapse set into an explicit assertion that they count separately.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 12:32:53 -07:00

864 lines
29 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package webapp
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/runbear-io/beardrive/internal/journal"
)
// Read telemetry: who consumes what, aggregated. Together with the write
// history the journals already carry, this completes the read×write matrix —
// heavily-read + long-unwritten is the danger zone an admin should fix first
// (see docs/design/read-heatmap.md).
//
// What counts as a read: viewer file/render/download hits (human), share-link
// hits (share), and agent tool reads reported by syncing devices (agent).
// /store/* sync traffic is replication, not reading, and is never counted;
// history /blob views are spelunking, not consumption, and aren't either.
//
// One exception to "never an event log": session reads (SessionRead), the
// per-session detail behind a History run card. They are a separate table
// with their own, shorter retention, and they never enter the ledger's
// in-memory bucket map — see SessionReadRepo for why that separation is the
// whole point.
//
// Privacy: rows are daily aggregation buckets, never an event log. The actor
// column (account email / device id / share token) exists only to count
// distinct readers and never appears in an API response — with exactly one
// stated exception, ?by=device (handleHeat), which reports the ids of agent
// devices. A device id is not a person and is already visible to every
// project member through History's device join; nothing else in the column —
// no email, no share token, no unowned device id — ever leaves the server.
// The exception is only sound because the ingest path validates the id
// (handleReadReport → ownsDevice): an actor recorded as an agent is shaped
// like a device id and is not one another account is syncing — never an
// arbitrary string, and never someone else's machine. This route also never
// registers a device: registering the id it is about to judge is what turned
// the round-2 check into a one-request speed bump.
//
// A session id is identity-adjacent and gets the same ruling, written down
// before anything serves it: a session id appears ONLY in History responses,
// on the op that carries it, and as a ?session= filter INPUT. It is never
// enumerated — no "list sessions" response, no session column in /heat's
// output, nothing new in ?by=device. That is sound because the id is already
// visible to every project member inside Op.Note today, so serving it as its
// own field discloses nothing new, while refusing to enumerate keeps /heat
// identity-free exactly as documented above.
// Read kinds.
const (
ReadKindHuman = "human"
ReadKindAgent = "agent"
ReadKindShare = "share"
)
// ReadStat is one aggregation bucket: reads of one path by one actor on one
// day. Day == "" is the all-time fold that survives retention.
type ReadStat struct {
Project string `json:"project"`
Path string `json:"path"`
Day string `json:"day"` // "2006-01-02" UTC, or "" for all-time
Kind string `json:"kind"`
Actor string `json:"actor"`
Count int64 `json:"count"`
Last time.Time `json:"last"`
}
// ReadStatKey identifies one bucket.
type ReadStatKey struct {
Project, Path, Day, Kind, Actor string
}
func (s ReadStat) key() ReadStatKey {
return ReadStatKey{s.Project, s.Path, s.Day, s.Kind, s.Actor}
}
// SessionRead records that one agent session read one path, from one device.
// Not a count and not a bucket: the run card asks "did this session read this
// file?", and one row per (session, device, path) answers it with no
// aggregation. Device is always the hub-validated device the report arrived
// from, never anything the client put in the body.
type SessionRead struct {
Project string `json:"project"`
Session string `json:"session"`
Device string `json:"device"`
Path string `json:"path"`
Last time.Time `json:"last"`
}
type sessionReadKey struct{ Project, Session, Device, Path string }
func (s SessionRead) key() sessionReadKey {
return sessionReadKey{s.Project, s.Session, s.Device, s.Path}
}
// HeatEntry is the per-path aggregate the heat API returns. Counts only —
// never identities.
type HeatEntry struct {
Human int64 `json:"human,omitempty"`
Agent int64 `json:"agent,omitempty"`
Share int64 `json:"share,omitempty"`
Readers int `json:"readers,omitempty"` // distinct human readers
LastRead time.Time `json:"last_read,omitzero"`
}
const (
// readDebounce collapses request storms (reloads, render-then-raw double
// fetches) into visits: repeat reads of a path by the same actor within
// the window don't count again.
readDebounce = 10 * time.Minute
// readFlushEvery throttles persistence; dirty buckets ride in memory
// between flushes, so a crash loses at most this much telemetry.
readFlushEvery = 30 * time.Second
// DefaultReadRetentionDays is how long daily buckets keep per-day
// resolution before folding into the all-time row.
DefaultReadRetentionDays = 400
// DefaultSessionRetentionDays is how long per-session read detail is
// kept. Much shorter than the bucket retention: this is event-shaped
// data whose only consumer is a History run card, and a month covers a
// retro. Rows past it are deleted, not folded — the heat totals were
// never derived from them, so nothing is lost from any count.
DefaultSessionRetentionDays = 30
// sessionPruneEvery throttles the retention delete; it rides the same
// flush the buckets use rather than owning a goroutine.
sessionPruneEvery = time.Hour
)
// ReadLedger is the in-memory read-telemetry service over a ReadRepo, in the
// mold of DeviceRegistry: reads stay in memory, writes are throttled. There is
// no background goroutine — flushes piggyback on Record calls, and telemetry
// failures never surface to the request that triggered them.
type ReadLedger struct {
repo ReadRepo
retention time.Duration
// Session-read detail, optional (nil = off) and deliberately outside
// byKey: these rows are never loaded into memory in bulk, so Heat's full
// map scan and the boot load are unaffected by session cardinality. If a
// future change ever moves them into byKey, Heat (below) is what pays.
sessions SessionReadRepo
sessionRetention time.Duration
// scans counts ShareOpens passes over byKey. Tests assert one per
// project per list render — the "never one scan per share" rule is
// invisible in the response body, so this is the only thing that can
// catch the regression.
scans atomic.Int64
mu sync.Mutex
byKey map[ReadStatKey]ReadStat
dirty map[ReadStatKey]bool
pendingDel []ReadStatKey // retention deletions awaiting a successful flush
seen map[ReadStatKey]time.Time // debounce; Day field unused ("")
pendingSess map[sessionReadKey]SessionRead
lastFlush time.Time
lastSessPrun time.Time
warned bool
sessWarned bool
}
// NewReadLedger loads the ledger and immediately folds buckets older than the
// retention horizon into their all-time rows. retentionDays <= 0 means the
// default.
func NewReadLedger(repo ReadRepo, retentionDays int) (*ReadLedger, error) {
if retentionDays <= 0 {
retentionDays = DefaultReadRetentionDays
}
l := &ReadLedger{
repo: repo,
retention: time.Duration(retentionDays) * 24 * time.Hour,
byKey: map[ReadStatKey]ReadStat{},
dirty: map[ReadStatKey]bool{},
seen: map[ReadStatKey]time.Time{},
lastFlush: time.Now(),
}
stats, err := repo.Load()
if err != nil {
return nil, err
}
for _, st := range stats {
l.byKey[st.key()] = st
}
// Fold anything past the retention horizon right away. A failed persist
// is not a boot failure — the fold stays dirty and later flushes retry.
l.mu.Lock()
l.compactLocked()
if err := l.persistLocked(); err != nil {
log.Printf("beardrive: read telemetry compact failed (will retry): %v", err)
}
l.mu.Unlock()
return l, nil
}
// OpenReadLedger loads the file-backed ledger at path.
func OpenReadLedger(path string, retentionDays int) (*ReadLedger, error) {
return NewReadLedger(newFileReadRepo(path), retentionDays)
}
// OpenSessionReadRepo is the file-backed session-read store, for hubs
// running without a MetaStore (the historical JSON-files layout).
func OpenSessionReadRepo(path string) SessionReadRepo { return newFileSessionReadRepo(path) }
// WithSessions turns on per-session read detail (the data behind a History
// run card). Separate from the constructor so every existing caller — and
// every backend that has no session repo — keeps working with it off.
// retentionDays <= 0 means the default.
func (l *ReadLedger) WithSessions(repo SessionReadRepo, retentionDays int) *ReadLedger {
if l == nil || repo == nil {
return l
}
if retentionDays <= 0 {
retentionDays = DefaultSessionRetentionDays
}
l.mu.Lock()
defer l.mu.Unlock()
l.sessions = repo
l.sessionRetention = time.Duration(retentionDays) * 24 * time.Hour
l.pendingSess = map[sessionReadKey]SessionRead{}
return l
}
// Record counts one read. Nil-safe and never fails: telemetry must not break
// the page view (or sync cycle) that triggered it.
func (l *ReadLedger) Record(project, path, kind, actor string) {
if l == nil || project == "" || path == "" {
return
}
now := time.Now().UTC()
l.mu.Lock()
defer l.mu.Unlock()
visit := ReadStatKey{Project: project, Path: path, Kind: kind, Actor: actor}
if t, ok := l.seen[visit]; ok && now.Sub(t) < readDebounce {
return
}
l.seen[visit] = now
key := visit
key.Day = now.Format("2006-01-02")
st := l.byKey[key]
st.Project, st.Path, st.Day, st.Kind, st.Actor = project, path, key.Day, kind, actor
st.Count++
st.Last = now
l.byKey[key] = st
l.dirty[key] = true
if now.Sub(l.lastFlush) >= readFlushEvery {
l.flushLocked()
}
}
// RecordSession notes that one agent session read one path from one device.
// Nil-safe, off when no session repo is configured, and — like Record —
// never fails: telemetry must not break the sync cycle that reported it.
// Unlike Record it is NOT debounced: a row is a fact ("this session read this
// file"), not a count, so repeats are the same row rewritten.
func (l *ReadLedger) RecordSession(project, session, device, path string) {
if l == nil || project == "" || session == "" || device == "" || path == "" {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if l.sessions == nil {
return
}
now := time.Now()
sr := SessionRead{Project: project, Session: session, Device: device, Path: path, Last: now.UTC()}
l.pendingSess[sr.key()] = sr
// Same throttle the buckets use. Record's own flush check sits behind its
// debounce return, so a report whose buckets are all debounced would
// otherwise leave these rows buffered indefinitely.
if now.Sub(l.lastFlush) >= readFlushEvery {
l.flushLocked()
}
}
// SessionPaths returns the paths one session read from one device, for the
// History run card. Both the session and the device are required by the
// caller (handleHeat): a session-only lookup would return rows a member
// reported under someone else's session id, which pinning to the validated
// device is what makes harmless.
func (l *ReadLedger) SessionPaths(project, session, device string) []string {
if l == nil || project == "" || session == "" || device == "" {
return nil
}
l.mu.Lock()
repo := l.sessions
// Flush first, so a card opened seconds after a sync sees that sync's
// reads instead of an empty list.
if repo != nil {
l.flushSessionsLocked()
}
l.mu.Unlock()
if repo == nil {
return nil
}
rows, err := repo.ListBySession(project, session, device)
if err != nil {
log.Printf("beardrive: session reads lookup failed: %v", err)
return nil
}
out := make([]string, 0, len(rows))
for _, r := range rows {
out = append(out, r.Path)
}
return out
}
// flushSessionsLocked persists buffered session rows and, at most hourly,
// deletes the ones past the session retention. Failures keep the buffer for
// the next attempt and log once — a read_sessions failure must never affect
// read_stats, so this is deliberately separate from persistLocked. Callers
// hold mu.
func (l *ReadLedger) flushSessionsLocked() {
if l.sessions == nil {
return
}
if len(l.pendingSess) > 0 {
batch := make([]SessionRead, 0, len(l.pendingSess))
for _, sr := range l.pendingSess {
batch = append(batch, sr)
}
if err := l.sessions.PutBatch(batch); err != nil {
if !l.sessWarned {
l.sessWarned = true
log.Printf("beardrive: session read flush failed (will retry): %v", err)
}
} else {
l.sessWarned = false
l.pendingSess = map[sessionReadKey]SessionRead{}
}
}
now := time.Now()
if now.Sub(l.lastSessPrun) < sessionPruneEvery {
return
}
l.lastSessPrun = now
if err := l.sessions.PruneBefore(now.UTC().Add(-l.sessionRetention)); err != nil {
log.Printf("beardrive: session read prune failed (will retry): %v", err)
}
}
// Heat aggregates reads per path for one project. since bounds the window
// (zero = all time, including retention folds); prefix "" means the whole
// project, otherwise paths under "<prefix>/".
func (l *ReadLedger) Heat(project, prefix string, since time.Time) map[string]HeatEntry {
if l == nil {
return nil
}
sinceDay := ""
if !since.IsZero() {
sinceDay = since.UTC().Format("2006-01-02")
}
prefix = strings.TrimSuffix(prefix, "/")
out := map[string]HeatEntry{}
humans := map[string]map[string]bool{} // path → distinct human actors
l.mu.Lock()
defer l.mu.Unlock()
for key, st := range l.byKey {
if key.Project != project {
continue
}
if prefix != "" && !strings.HasPrefix(key.Path, prefix+"/") {
continue
}
if key.Day == "" {
if sinceDay != "" {
continue // all-time fold is older than any windowed query
}
} else if key.Day < sinceDay {
continue
}
e := out[key.Path]
switch key.Kind {
case ReadKindHuman:
e.Human += st.Count
set := humans[key.Path]
if set == nil {
set = map[string]bool{}
humans[key.Path] = set
}
set[key.Actor] = true
case ReadKindAgent:
e.Agent += st.Count
case ReadKindShare:
e.Share += st.Count
}
if st.Last.After(e.LastRead) {
e.LastRead = st.Last
}
out[key.Path] = e
}
for p, set := range humans {
e := out[p]
e.Readers = len(set)
out[p] = e
}
return out
}
// AgentHeat aggregates agent reads per device per top-level folder ("" for
// root files) — the coverage-matrix data. Agent buckets only, by design:
// agent actors are device ids, which history already exposes; human actors
// (emails) must never leave the server, so human/share buckets are not
// consulted at all.
func (l *ReadLedger) AgentHeat(project string, since time.Time) map[string]map[string]int64 {
if l == nil {
return nil
}
sinceDay := ""
if !since.IsZero() {
sinceDay = since.UTC().Format("2006-01-02")
}
out := map[string]map[string]int64{}
l.mu.Lock()
defer l.mu.Unlock()
for key, st := range l.byKey {
if key.Project != project || key.Kind != ReadKindAgent {
continue
}
if key.Day == "" {
if sinceDay != "" {
continue
}
} else if key.Day < sinceDay {
continue
}
folder := ""
if i := strings.IndexByte(key.Path, '/'); i >= 0 {
folder = key.Path[:i]
}
m := out[key.Actor]
if m == nil {
m = map[string]int64{}
out[key.Actor] = m
}
m[folder] += st.Count
}
return out
}
// ShareOpen is share-link consumption for one path: visits, and when.
type ShareOpen struct {
Count int64
Last time.Time
}
// ShareOpens aggregates share-kind reads per path for one project — the
// receipt a person who shared something actually wants. All-time, because a
// link's lifetime is the question a receipt answers.
//
// Share buckets only, and that is what makes Last mean *last opened*:
// HeatEntry.LastRead is cross-kind, so a member viewing the file in the hub
// would otherwise move the "opened through the link" date.
//
// Counts, never identities — the share actor is token+"/"+IP+"/"+UA hash, a
// public credential joined to a network and a browser, and it must not leave
// the ledger. There is deliberately no distinct-openers field.
//
// One byKey scan per project, never one per share: callers build this map
// once and index it, because byKey is the full map and a project with 40
// links would otherwise pay 40 full scans per list render.
func (l *ReadLedger) ShareOpens(project string) map[string]ShareOpen {
if l == nil {
return nil // reads disabled: absent, not zero
}
l.scans.Add(1)
out := map[string]ShareOpen{}
l.mu.Lock()
defer l.mu.Unlock()
for key, st := range l.byKey {
if key.Project != project || key.Kind != ReadKindShare {
continue
}
// No day filter: both the daily buckets and the folded Day == ""
// all-time row count.
e := out[key.Path]
e.Count += st.Count
if st.Last.After(e.Last) {
e.Last = st.Last
}
out[key.Path] = e
}
return out
}
// Close flushes any pending buckets.
func (l *ReadLedger) Close() error {
if l == nil {
return nil
}
l.mu.Lock()
defer l.mu.Unlock()
l.flushLocked()
if n := len(l.dirty); n > 0 {
return fmt.Errorf("read ledger: flush failed, %d buckets pending", n)
}
return nil
}
// flushLocked persists dirty buckets (and, once a day, retention folds),
// pruning the debounce map along the way. Failures keep the buckets dirty for
// the next attempt and log once — telemetry never breaks a request.
func (l *ReadLedger) flushLocked() {
now := time.Now()
l.lastFlush = now
for k, t := range l.seen {
if now.Sub(t) >= readDebounce {
delete(l.seen, k)
}
}
l.compactLocked()
if err := l.persistLocked(); err != nil {
if !l.warned {
l.warned = true
log.Printf("beardrive: read telemetry flush failed (will retry): %v", err)
}
} else {
l.warned = false
}
l.flushSessionsLocked()
}
// compactLocked folds daily buckets older than the retention horizon into
// their all-time rows, queueing the daily rows for deletion. Callers hold mu.
func (l *ReadLedger) compactLocked() {
horizon := time.Now().UTC().Add(-l.retention).Format("2006-01-02")
for key, st := range l.byKey {
if key.Day == "" || key.Day >= horizon {
continue
}
fold := key
fold.Day = ""
agg := l.byKey[fold]
agg.Project, agg.Path, agg.Kind, agg.Actor = st.Project, st.Path, st.Kind, st.Actor
agg.Day = ""
agg.Count += st.Count
if st.Last.After(agg.Last) {
agg.Last = st.Last
}
l.byKey[fold] = agg
l.dirty[fold] = true
delete(l.byKey, key)
delete(l.dirty, key)
l.pendingDel = append(l.pendingDel, key)
}
}
// persistLocked writes queued deletions and dirty buckets through the repo.
// Callers hold mu. Both queues survive a failure so the next flush retries —
// dropping a deletion would resurrect folded rows on the next load and
// double-count them.
func (l *ReadLedger) persistLocked() error {
if len(l.pendingDel) > 0 {
if err := l.repo.DeleteBatch(l.pendingDel); err != nil {
// Same one-transaction problem as the put path below, worse for
// being first: a key the store will never accept parks here
// forever and PutBatch is then never reached at all, so the whole
// hub's telemetry stops persisting. Retry one at a time; if some
// land, the ones that did not are keys this store will never
// accept, so drop them. If none land the store is down —
// transient — and the queue stands for the next flush.
if len(l.pendingDel) == 1 {
return err
}
var stuck []ReadStatKey
landed := 0
for _, key := range l.pendingDel {
if l.repo.DeleteBatch([]ReadStatKey{key}) == nil {
landed++
} else {
stuck = append(stuck, key)
}
}
if landed == 0 {
return err
}
for _, key := range stuck {
log.Printf("beardrive: read telemetry dropped an undeletable bucket (project %s, path %q): %v",
key.Project, key.Path, err)
}
}
l.pendingDel = nil
}
if len(l.dirty) == 0 {
return nil
}
batch := make([]ReadStat, 0, len(l.dirty))
for key := range l.dirty {
batch = append(batch, l.byKey[key])
}
err := l.repo.PutBatch(batch)
if err == nil {
l.dirty = map[ReadStatKey]bool{}
return nil
}
if len(batch) == 1 {
return err
}
// One transaction, so one bucket the store refuses takes every other
// bucket down with it — and, because they stay dirty, every bucket the hub
// counts from then on. That is a hub-wide telemetry kill from the lowest
// privilege there is (Postgres rejects a NUL byte in a path; sqlite and
// the file backend store it happily). Retry one at a time: if some land,
// the ones that did not are content this store will never accept, so drop
// them rather than wedge the queue. If none land the store itself is
// down — transient — and everything stays dirty for the next flush.
var stuck []ReadStatKey
landed := 0
for key := range l.dirty {
if l.repo.PutBatch([]ReadStat{l.byKey[key]}) == nil {
delete(l.dirty, key)
landed++
} else {
stuck = append(stuck, key)
}
}
if landed == 0 {
return err
}
for _, key := range stuck {
log.Printf("beardrive: read telemetry dropped an unstorable bucket (project %s, path %q): %v",
key.Project, key.Path, err)
delete(l.dirty, key)
}
return nil
}
// hasControlChars reports whether s carries a C0/C7F control character —
// never legitimate in a path, and fatal to a Postgres text column.
func hasControlChars(s string) bool {
return strings.ContainsFunc(s, func(r rune) bool { return r < 0x20 || r == 0x7f })
}
// ---- server integration ----
// ctxProjectKey carries the resolved project id from the proj() route
// resolver to handlers that record reads.
type ctxProjectKey struct{}
func withProjectID(r *http.Request, id string) *http.Request {
return r.WithContext(context.WithValue(r.Context(), ctxProjectKey{}, id))
}
func projectID(r *http.Request) string {
id, _ := r.Context().Value(ctxProjectKey{}).(string)
return id
}
// recordRead counts a human read of path for the request's project. No-op
// outside hub mode (no project id) or when read tracking is off.
func (s *Server) recordRead(r *http.Request, path string) {
if s.Reads == nil {
return
}
project := projectID(r)
if project == "" {
return
}
actor := s.requestUser(r).Email
if actor == "" {
actor = "anonymous"
}
s.Reads.Record(project, path, ReadKindHuman, actor)
}
// handleHeat serves per-path read aggregates: ?prefix= bounds to a folder,
// ?days= bounds the window (default 30, 0 = all time). With ?by=device it
// returns the agent-kind breakdown instead: per device (registry-joined),
// reads per top-level folder — the one place an actor id is reported, and
// only ever a device the reporting account owned (see the package comment).
// Human and share actors never leave the server in any shape.
func (s *Server) handleHeat(v *volume, w http.ResponseWriter, r *http.Request) {
if s.Reads == nil {
http.Error(w, "read tracking is not enabled on this server", http.StatusNotFound)
return
}
_ = v
q := r.URL.Query()
// ?session=&device= is the run-card join: which paths that agent session
// read. Both are required — a session-only query would also return rows a
// member reported under someone else's session id, which pinning the row
// to the reporting device (handleReadReport) is what makes harmless. This
// is a filter INPUT only: nothing here or anywhere else enumerates
// sessions, and the response carries paths, no identities and no counts.
if session := q.Get("session"); session != "" || q.Get("device") != "" {
device := q.Get("device")
if session == "" || device == "" {
http.Error(w, "session and device must be given together", http.StatusBadRequest)
return
}
paths := s.Reads.SessionPaths(projectID(r), session, device)
if paths == nil {
paths = []string{} // an empty list, never a null the client must special-case
}
writeJSON(w, map[string]any{"paths": paths})
return
}
days := 30
if raw := q.Get("days"); raw != "" {
var err error
if days, err = strconv.Atoi(raw); err != nil || days < 0 {
http.Error(w, "invalid days", http.StatusBadRequest)
return
}
}
var since time.Time
if days > 0 {
since = time.Now().UTC().AddDate(0, 0, -days)
}
switch q.Get("by") {
case "":
case "device":
s.heatByDevice(w, projectID(r), since)
return
default:
http.Error(w, "invalid by (use device)", http.StatusBadRequest)
return
}
entries := s.Reads.Heat(projectID(r), q.Get("prefix"), since)
out := map[string]any{"entries": entries}
if !since.IsZero() {
out["since"] = since.Format("2006-01-02")
}
writeJSON(w, out)
}
// deviceHeat is one row of the ?by=device response.
type deviceHeat struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
OS string `json:"os,omitempty"`
Folders map[string]int64 `json:"folders"`
Total int64 `json:"total"`
}
func (s *Server) heatByDevice(w http.ResponseWriter, project string, since time.Time) {
byDevice := s.Reads.AgentHeat(project, since)
visible := s.deviceVisibleIn(project)
devices := make([]deviceHeat, 0, len(byDevice))
for id, folders := range byDevice {
d := deviceHeat{ID: id, Folders: folders}
// Scoped join: a device owned by an account outside this project's org
// contributes no name or OS, so heat cannot become a window onto
// another org's machines.
if info, ok := s.Devices.LookupIn(id, visible); ok {
d.Name, d.OS = info.Name, info.OS
}
for _, n := range folders {
d.Total += n
}
devices = append(devices, d)
}
sort.Slice(devices, func(i, j int) bool {
if devices[i].Total != devices[j].Total {
return devices[i].Total > devices[j].Total
}
return devices[i].ID < devices[j].ID
})
out := map[string]any{"devices": devices}
if !since.IsZero() {
out["since"] = since.Format("2006-01-02")
}
writeJSON(w, out)
}
// handleReadReport ingests agent reads from a syncing device: the client's
// read spool, drained best-effort at sync time. Requires a device identity —
// the device id is the actor, so reads count as agent traffic.
func (s *Server) handleReadReport(v *volume, w http.ResponseWriter, r *http.Request) {
if s.Reads == nil {
http.Error(w, "read tracking is not enabled on this server", http.StatusNotFound)
return
}
device := deviceID(r)
if device == "" {
http.Error(w, "agent read reports need a device identity", http.StatusBadRequest)
return
}
var req struct {
Reads []struct {
Path string `json:"path"`
// Session is the agent session the read happened in — a CLIENT
// string, so it is only ever stored alongside the device the hub
// validated below, never on its own. See the row write.
Session string `json:"session,omitempty"`
// Time is accepted for forward compatibility but buckets use
// server time: client clocks are unreliable and late flushes are
// telemetry noise, not data loss.
Time time.Time `json:"time,omitzero"`
} `json:"reads"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
if len(req.Reads) > 4096 {
http.Error(w, "too many reads in one report", http.StatusBadRequest)
return
}
// The device id becomes the actor these buckets are keyed by, and /heat
// reports agent actors — so an unvalidated header would let any member
// plant any string (an id from another org, or an account email) and have
// the hub serve it back to the whole project as a reader. This route
// deliberately does NOT observe the device: registering the id it is about
// to judge is what made the round-2 check a one-request speed bump. Only
// /store/* traffic registers a device.
mine := s.ownsDevice(r, device)
// A reported path is a claim about a file, and the heat map is what the
// Dashboard's reads-x-staleness quadrant is built from — the view an
// operator reads to decide what is stale. Any member with PermRead could
// report any string, so the quadrant was member-writable fiction: a
// "compliance/soc2-evidence-2026.md" nobody ever wrote showed up as read.
// The project's own replayed state is the only thing that can say a path is
// real, and it is right here. A snapshot the store cannot produce records
// nothing this cycle: the client's spool is drained best-effort and retried,
// and telemetry must never fail a request (nor invent one).
snap, err := v.snapshot(r.Context())
if err != nil {
writeJSON(w, map[string]any{"accepted": 0})
return
}
project := projectID(r)
n := 0
for _, e := range req.Reads {
// A path is a bucket key that reaches the metadata store: a control
// character (a NUL above all) is rejected outright by Postgres, and a
// row the store will never accept has to be refused here rather than
// discovered at flush time.
// journal.SafePath is the rule, in the one place it is defined. This
// was a fourth copy of it, and it disagreed in both directions: it
// accepted "/etc/passwd", "a//b" and "./a", and refused "my..file".
if !journal.SafePath(e.Path) {
continue
}
if !mine {
continue // not this account's device: counted for nobody
}
if _, real := snap.files[e.Path]; !real {
continue // no such file in this project: a read of nothing is not a read
}
s.Reads.Record(project, e.Path, ReadKindAgent, device)
// The session id is the one field here the hub cannot vouch for: it
// arrives in the body, so any member could report reads naming a
// teammate's session and paint files onto that teammate's run card.
// The row is therefore pinned to `device` — the id ownsDevice just
// validated — and the query side requires BOTH session and device, so
// a forged row can only ever be found under the forger's own device,
// which MayActAs guarantees is never someone else's.
if sess := trimText(e.Session, 128); sess != "" && journal.SafeText(sess) {
s.Reads.RecordSession(project, sess, device, e.Path)
}
n++
}
writeJSON(w, map[string]any{"accepted": n})
}