mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
History showed what an agent run CHANGED. What it read lived in a daily aggregate with no session dimension, so the two could not be joined and nobody could answer "when my agent answered, what did it look at — and was it the fresh version or archive/retired-spec.md?". The join is one string carried through four places: hook -> spool -> hub -> run card. A run card now marks each change the run also read, lists the files it read and never touched, and says on screen why a read can be missing. The three landmines the issue asks be named here: 1. Op.Note is USER-SETTABLE (`bdrive sync --note`), so joining reads to writes on the note string would let any member with write access forge a note that collides with a teammate's run card and hang their reads off it. Fixed by adding journal.Op.Session — set only by `bdrive sync --hook`, never by --note — and joining on that. The note stays settable and stays untrusted; the join simply never reads it. Op.Session is additive JSONL and, like Mtime, is never an input to Less or Replay, so replay determinism is untouched and older ops carry "". The read half has the same hole one step further on: POST /reads takes the session id from the CLIENT, so a member could report reads under a teammate's session and paint files onto their card. Every session row is therefore pinned to the ownsDevice-validated device, and the query requires ?session= AND ?device= together — a forged row can only be found under the forger's own device, which MayActAs guarantees is never somebody else's. 2. BUCKET CARDINALITY. Putting the session in the read_stats key would take a 2k-file project from ~2k to ~100k rows/day, into a table ReadLedger loads whole at boot and full-scans on every heat request, hub-wide — so it would slow the Dashboard for projects that never ran an agent. This is the escape hatch the spec itself names, taken up front: session rows live in their own read_sessions repo, outside ReadLedger.byKey. No read_stats PK migration, no change to the resident-row count, ?by=device byte-identical. They get their own retention (session_retention_days, default 30) which DELETES rather than folds — no heat total was ever derived from them. 3. READS ARE RECORDED ONLY FOR PATHS IN THE CURRENT REPLAY, so a session that read a file it then deleted shows a change with no read. That is by design, and the run card says so in its footer rather than leaving it to read as a bug. Privacy ruling, written into internal/webapp/reads.go 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 listing endpoint, no session column in /heat output, nothing new in ?by=device. Also: PendingReads now dedupes on (path, session), not path alone. Two agent sessions on one device between syncs used to collapse into one event carrying whichever session flushed last — one session's reads silently credited to another. Tests: journal round-trip + Less-ignores-Session; the forge test (`sync --note "claude-code session <someone-else's>"` leaves Session empty); a multi-device syncer test carrying the session through convergence; spool per-session dedup; hub round-trip, cross-device forge, query contract and non-enumeration; db_conformance on file, sqlite AND postgres; runs.ts grouping incl. legacy fallback; a Playwright spec on the seeded run card.
441 lines
16 KiB
Go
441 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/runbear-io/beardrive/internal/config"
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
|
"github.com/runbear-io/beardrive/internal/webapp"
|
|
)
|
|
|
|
// webConfig mirrors the serve command's flags so a server can be configured
|
|
// from a file (bdrive serve -c config.json). Explicitly-passed flags win over
|
|
// file values.
|
|
type webConfig struct {
|
|
Remote string `json:"remote,omitempty"`
|
|
Dir string `json:"dir,omitempty"`
|
|
Addr string `json:"addr,omitempty"`
|
|
Volume string `json:"volume,omitempty"`
|
|
Refresh string `json:"refresh,omitempty"` // duration, e.g. "10s"
|
|
Upload *bool `json:"upload,omitempty"`
|
|
UploadTTL string `json:"upload_ttl,omitempty"` // duration, e.g. "15m"
|
|
ProjectsDB string `json:"projects_db,omitempty"` // hub project registry path
|
|
ShareRPM int `json:"share_rpm,omitempty"` // per-IP rate on /s/* (default 120/min)
|
|
// TrustProxy makes the rate limiters read the client address from
|
|
// X-Forwarded-For sent by ANY peer. Usually unnecessary: a proxy that
|
|
// reaches the hub over loopback or a private network is trusted with no
|
|
// configuration. Set it only for a proxy on a PUBLIC address — on a
|
|
// directly-reachable hub the header is client-supplied, and trusting it
|
|
// lets one connection get a fresh bucket per request, which disables the
|
|
// share limiter and the login brute-force limiter alike.
|
|
TrustProxy bool `json:"trust_proxy,omitempty"`
|
|
// Auth tunes the hub's (always-on) authentication; hubs require
|
|
// sign-in unconditionally, only these knobs are optional.
|
|
Auth *struct {
|
|
AllowSignup *bool `json:"allow_signup,omitempty"` // default true
|
|
UsersDB string `json:"users_db,omitempty"` // default $BDRIVE_HOME/auth.json
|
|
AllowedDomains []string `json:"allowed_domains,omitempty"` // signup email must match one (e.g. ["runbear.io"])
|
|
RequireVerification *bool `json:"require_verification,omitempty"` // new accounts verify email before activation
|
|
RequireApproval *bool `json:"require_approval,omitempty"` // new accounts await admin approval
|
|
Admins []string `json:"admins,omitempty"` // hub admin emails (approve users, govern shares)
|
|
Brand string `json:"brand,omitempty"` // name shown on the sign-in page
|
|
BaseURL string `json:"base_url,omitempty"` // public origin used for MAILED links (never the request's Host)
|
|
SMTP *struct {
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
User string `json:"user,omitempty"`
|
|
Pass string `json:"pass,omitempty"`
|
|
From string `json:"from,omitempty"`
|
|
} `json:"smtp,omitempty"`
|
|
} `json:"auth,omitempty"`
|
|
// Database selects where hub metadata (accounts, projects, orgs, invites,
|
|
// shares, devices) lives. Default "file" (JSON under BDRIVE_HOME). Blobs
|
|
// and journals always stay in the object store, never the database.
|
|
Database *struct {
|
|
Driver string `json:"driver,omitempty"` // file (default) | sqlite | postgres
|
|
DSN string `json:"dsn,omitempty"` // sqlite file path, or a Postgres/Supabase URL
|
|
} `json:"database,omitempty"`
|
|
// Reads tunes read telemetry (the heat API): aggregate view counts per
|
|
// file, split human/agent/share. On by default in hub mode; counts only,
|
|
// no reader identities in any API.
|
|
Reads *struct {
|
|
Enabled *bool `json:"enabled,omitempty"` // default true
|
|
RetentionDays int `json:"retention_days,omitempty"` // default 400; older days fold into all-time
|
|
// SessionRetentionDays bounds the per-session read detail behind
|
|
// History's run cards (which files an agent session read). Default
|
|
// 30, and deliberately much shorter than RetentionDays: this is
|
|
// event-shaped rather than aggregate, and rows past it are deleted,
|
|
// which changes no heat total.
|
|
SessionRetentionDays int `json:"session_retention_days,omitempty"`
|
|
} `json:"reads,omitempty"`
|
|
}
|
|
|
|
func loadWebConfig(path string) (webConfig, error) {
|
|
var c webConfig
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return c, err
|
|
}
|
|
dec := json.NewDecoder(bytes.NewReader(data))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(&c); err != nil {
|
|
return c, fmt.Errorf("parse %s: %w", path, err)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func webCmd() *cobra.Command {
|
|
var remoteURL, dir, volume string
|
|
var addr, configPath, projectsDB string
|
|
var refresh, uploadTTL time.Duration
|
|
var upload bool
|
|
var cfg webConfig
|
|
c := &cobra.Command{
|
|
Use: "serve [folder | storage-root-url]",
|
|
Aliases: []string{"web"},
|
|
Short: "Serve the bdrive web server: viewer, uploads, and sync hub",
|
|
Long: `Serve the bdrive web server: browse folders and files, read rendered
|
|
markdown (Obsidian-style, including [[wikilinks]]), and download any file.
|
|
|
|
Two modes:
|
|
|
|
- a local folder, served straight from disk (the default — on a mounted
|
|
folder the daemon keeps it fresh, so this is the simplest viewer);
|
|
- a hub: point it at an object-storage root and it hosts many projects,
|
|
each stored under its own prefix, managed by a file-backed project
|
|
registry. Client devices run "bdrive login <this server>" once, then
|
|
"bdrive init" per project, and sync whole folders through it without
|
|
ever seeing the storage location or holding cloud credentials.
|
|
|
|
The server is read-only unless --upload is set. With uploads on, content
|
|
travels directly between clients and the object store through short-lived
|
|
presigned URLs when the backend supports it (S3, GCS with signing
|
|
credentials); otherwise it is relayed through this server.`,
|
|
Example: ` bdrive serve # serve the current directory
|
|
bdrive serve ./notes # serve a folder
|
|
bdrive serve -c config.json # everything from a config file
|
|
bdrive serve s3://bucket/root --upload # multi-project sync hub
|
|
|
|
bdrive web # deprecated alias for "bdrive serve"`,
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
// Config file first; flags that were explicitly passed override
|
|
// its values.
|
|
if configPath != "" {
|
|
c, err := loadWebConfig(configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg = c
|
|
set := cmd.Flags().Changed
|
|
if c.Remote != "" && !set("remote") {
|
|
remoteURL = c.Remote
|
|
}
|
|
if c.Dir != "" && !set("dir") {
|
|
dir = c.Dir
|
|
}
|
|
if c.Addr != "" && !set("addr") {
|
|
addr = c.Addr
|
|
}
|
|
if c.Volume != "" && !set("volume") {
|
|
volume = c.Volume
|
|
}
|
|
if c.Upload != nil && !set("upload") {
|
|
upload = *c.Upload
|
|
}
|
|
if c.Refresh != "" && !set("refresh") {
|
|
d, err := time.ParseDuration(c.Refresh)
|
|
if err != nil {
|
|
return fmt.Errorf("config refresh: %w", err)
|
|
}
|
|
refresh = d
|
|
}
|
|
if c.UploadTTL != "" && !set("upload-ttl") {
|
|
d, err := time.ParseDuration(c.UploadTTL)
|
|
if err != nil {
|
|
return fmt.Errorf("config upload_ttl: %w", err)
|
|
}
|
|
uploadTTL = d
|
|
}
|
|
if c.ProjectsDB != "" && !set("projects-db") {
|
|
projectsDB = c.ProjectsDB
|
|
}
|
|
}
|
|
// Positional argument: a URL selects remote mode, anything else
|
|
// is a folder. With nothing specified, serve the current dir.
|
|
if remoteURL == "" && dir == "" && len(args) > 0 {
|
|
if strings.Contains(args[0], "://") {
|
|
remoteURL = args[0]
|
|
} else {
|
|
dir = args[0]
|
|
}
|
|
}
|
|
if remoteURL != "" && dir != "" {
|
|
return fmt.Errorf("--remote and --dir are mutually exclusive")
|
|
}
|
|
if remoteURL == "" && dir == "" {
|
|
dir = "."
|
|
}
|
|
// A config that asks for gating this mode cannot provide is
|
|
// refused, not silently honoured in part. The whole auth block —
|
|
// allowed_domains, require_approval, allow_signup, admins — is
|
|
// built only in the hub branch, so with a `dir` the operator got a
|
|
// server anyone can read and a config file that said otherwise.
|
|
// Same posture as ValidateSignupPolicy, which exists for exactly
|
|
// this and lives inside the branch that never runs here.
|
|
if dir != "" && cfg.Auth != nil {
|
|
return fmt.Errorf("the `auth` block configures a hub's sign-in and a `dir` selects " +
|
|
"the single-volume viewer, which is auth-free by design: remove one of them " +
|
|
"(use `remote:` to run a hub)")
|
|
}
|
|
|
|
srv := &webapp.Server{
|
|
Refresh: refresh,
|
|
Upload: webapp.UploadConfig{Enabled: upload, TTL: uploadTTL},
|
|
ShareRPM: cfg.ShareRPM,
|
|
TrustProxy: cfg.TrustProxy,
|
|
}
|
|
var display string
|
|
var meta webapp.MetaStore // hub metadata store; nil means the file backend
|
|
if dir != "" {
|
|
// Single-volume viewer over a plain folder.
|
|
abs, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if fi, err := os.Stat(abs); err != nil || !fi.IsDir() {
|
|
return fmt.Errorf("%s is not a directory", abs)
|
|
}
|
|
srv.Source = &webapp.DirSource{Root: abs}
|
|
srv.Volume = filepath.Base(abs)
|
|
display = abs
|
|
} else {
|
|
// Hub: many projects on one storage root.
|
|
be, err := remote.Open(cmd.Context(), remoteURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer be.Close()
|
|
if projectsDB == "" {
|
|
home, err := config.Home()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
projectsDB = filepath.Join(home, "projects.json")
|
|
}
|
|
// Pick the metadata backend: file (default) or a SQL database
|
|
// (sqlite locally, Postgres/Supabase in production).
|
|
if cfg.Database != nil {
|
|
switch cfg.Database.Driver {
|
|
case "", "file":
|
|
case "sqlite", "postgres", "pgx":
|
|
drv := cfg.Database.Driver
|
|
if drv == "postgres" {
|
|
drv = "pgx"
|
|
}
|
|
meta, err = webapp.OpenSQLStore(drv, cfg.Database.DSN)
|
|
if err != nil {
|
|
return fmt.Errorf("open database: %w", err)
|
|
}
|
|
defer meta.Close()
|
|
default:
|
|
return fmt.Errorf("unknown database driver %q (use file, sqlite, or postgres)", cfg.Database.Driver)
|
|
}
|
|
}
|
|
var db *webapp.ProjectDB
|
|
if meta != nil {
|
|
db, err = webapp.NewProjectDB(meta.Projects())
|
|
} else {
|
|
db, err = webapp.OpenProjectDB(projectsDB)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("open project registry: %w", err)
|
|
}
|
|
dev, err := config.LoadDevice()
|
|
if err != nil {
|
|
return fmt.Errorf("load device identity: %w", err)
|
|
}
|
|
srv.Root = be
|
|
srv.Projects = db
|
|
srv.Device = webapp.Identity{ID: dev.ID, Name: dev.Name, Author: dev.Author}
|
|
// Display name only — and it goes out in /api/config, which is
|
|
// readable anonymously, so it must not be derived from the
|
|
// storage URL: on s3://acme-prod-drive that named the bucket
|
|
// to the whole internet. `--volume` / `volume:` stays the one
|
|
// way an operator puts a storage-flavoured string on the wire.
|
|
srv.Volume = "BearDrive"
|
|
if meta != nil {
|
|
display = remoteURL
|
|
} else {
|
|
display = remoteURL + " (projects: " + projectsDB + ")"
|
|
}
|
|
}
|
|
if volume != "" {
|
|
srv.Volume = volume
|
|
}
|
|
|
|
// Hubs always require sign-in: every op needs a real account
|
|
// behind it (history, device registry). The plain-folder viewer
|
|
// stays auth-free.
|
|
if srv.Root != nil {
|
|
usersDB := ""
|
|
// Invite-only by default: a fresh hub doesn't accept self-signup
|
|
// until an admin opts in (with a gate). Owners onboard people
|
|
// with /join/<token> invite links.
|
|
allowSignup := false
|
|
var mail *webapp.Mailer
|
|
if cfg.Auth != nil {
|
|
usersDB = cfg.Auth.UsersDB
|
|
if cfg.Auth.AllowSignup != nil {
|
|
allowSignup = *cfg.Auth.AllowSignup
|
|
}
|
|
if cfg.Auth.SMTP != nil {
|
|
mail = &webapp.Mailer{
|
|
Host: cfg.Auth.SMTP.Host, Port: cfg.Auth.SMTP.Port,
|
|
User: cfg.Auth.SMTP.User, Pass: cfg.Auth.SMTP.Pass,
|
|
From: cfg.Auth.SMTP.From,
|
|
}
|
|
}
|
|
}
|
|
home, err := config.Home()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if usersDB == "" {
|
|
usersDB = filepath.Join(home, "auth.json")
|
|
}
|
|
var auth *webapp.BuiltinAuth
|
|
if meta != nil {
|
|
auth, err = webapp.NewBuiltinAuth(meta.Accounts(), allowSignup, mail)
|
|
} else {
|
|
auth, err = webapp.OpenBuiltinAuth(usersDB, allowSignup, mail)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("open account registry: %w", err)
|
|
}
|
|
if cfg.Auth != nil {
|
|
auth.AllowedDomains = cfg.Auth.AllowedDomains
|
|
// Toggles: an explicit config value pins the setting each
|
|
// boot; otherwise the UI-saved policy (loaded from auth.json)
|
|
// stands.
|
|
if cfg.Auth.RequireVerification != nil {
|
|
auth.RequireVerification = *cfg.Auth.RequireVerification
|
|
}
|
|
if cfg.Auth.RequireApproval != nil {
|
|
auth.RequireApproval = *cfg.Auth.RequireApproval
|
|
}
|
|
auth.Brand = cfg.Auth.Brand
|
|
auth.BaseURL = cfg.Auth.BaseURL
|
|
if len(cfg.Auth.Admins) > 0 {
|
|
auth.Admins = make(map[string]bool, len(cfg.Auth.Admins))
|
|
for _, e := range cfg.Auth.Admins {
|
|
auth.Admins[strings.ToLower(strings.TrimSpace(e))] = true
|
|
}
|
|
}
|
|
}
|
|
// Refuse to boot an incoherent signup posture (open with no
|
|
// gate, or verification without a mailer) rather than silently
|
|
// leaving the door open.
|
|
if err := auth.ValidateSignupPolicy(); err != nil {
|
|
return err
|
|
}
|
|
srv.Auth = auth
|
|
var orgs *webapp.OrgDB
|
|
if meta != nil {
|
|
orgs, err = webapp.NewOrgDB(meta.Orgs())
|
|
} else {
|
|
orgs, err = webapp.OpenOrgDB(filepath.Join(filepath.Dir(projectsDB), "orgs.json"))
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("open org registry: %w", err)
|
|
}
|
|
srv.Dir = webapp.LocalDirectory{OrgDB: orgs}
|
|
// Invite links can bootstrap an account on an invite-only hub.
|
|
auth.InviteValid = orgs.ValidInvite
|
|
// A hub that predates organizations: sweep its projects into
|
|
// a default org so it keeps working with zero manual steps.
|
|
if err := webapp.MigrateOrgs(srv.Projects, orgs, auth.Accounts()); err != nil {
|
|
return fmt.Errorf("migrate projects into orgs: %w", err)
|
|
}
|
|
var devices *webapp.DeviceRegistry
|
|
if meta != nil {
|
|
devices, err = webapp.NewDeviceRegistry(meta.Devices())
|
|
} else {
|
|
devices, err = webapp.OpenDeviceRegistry(filepath.Join(filepath.Dir(projectsDB), "devices.json"))
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("open device registry: %w", err)
|
|
}
|
|
srv.Devices = devices
|
|
var shares *webapp.ShareDB
|
|
if meta != nil {
|
|
shares, err = webapp.NewShareDB(meta.Shares())
|
|
} else {
|
|
shares, err = webapp.OpenShareDB(filepath.Join(filepath.Dir(projectsDB), "shares.json"))
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("open share registry: %w", err)
|
|
}
|
|
srv.Shares = shares
|
|
readsOn, retention, sessRetention := true, 0, 0
|
|
if cfg.Reads != nil {
|
|
if cfg.Reads.Enabled != nil {
|
|
readsOn = *cfg.Reads.Enabled
|
|
}
|
|
retention = cfg.Reads.RetentionDays
|
|
sessRetention = cfg.Reads.SessionRetentionDays
|
|
}
|
|
if readsOn {
|
|
var reads *webapp.ReadLedger
|
|
var sessions webapp.SessionReadRepo
|
|
if meta != nil {
|
|
reads, err = webapp.NewReadLedger(meta.Reads(), retention)
|
|
sessions = meta.SessionReads()
|
|
} else {
|
|
dir := filepath.Dir(projectsDB)
|
|
reads, err = webapp.OpenReadLedger(filepath.Join(dir, "reads.json"), retention)
|
|
sessions = webapp.OpenSessionReadRepo(filepath.Join(dir, "sessions.json"))
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("open read ledger: %w", err)
|
|
}
|
|
reads.WithSessions(sessions, sessRetention)
|
|
defer reads.Close()
|
|
srv.Reads = reads
|
|
}
|
|
if meta != nil {
|
|
display += " (db: " + cfg.Database.Driver + ")"
|
|
} else {
|
|
display += " (auth: " + usersDB + ")"
|
|
}
|
|
}
|
|
|
|
shown := addr
|
|
if strings.HasPrefix(shown, ":") {
|
|
shown = "localhost" + shown
|
|
}
|
|
fmt.Printf("serving %s\n volume: %s\n url: http://%s\n", display, srv.Volume, shown)
|
|
return http.ListenAndServe(addr, srv.Handler())
|
|
},
|
|
}
|
|
c.Flags().StringVarP(&remoteURL, "remote", "r", "", "remote to serve (s3://bucket/prefix, gs://bucket/prefix, file:///path)")
|
|
c.Flags().StringVar(&dir, "dir", "", "local folder to serve (default: current directory)")
|
|
c.Flags().StringVar(&addr, "addr", ":4173", "address to listen on")
|
|
c.Flags().StringVarP(&volume, "volume", "v", "", "volume display name (default: folder or remote basename)")
|
|
c.Flags().DurationVar(&refresh, "refresh", 10*time.Second, "how long to cache the file listing")
|
|
c.Flags().BoolVar(&upload, "upload", false, "allow clients to upload files")
|
|
c.Flags().DurationVar(&uploadTTL, "upload-ttl", webapp.DefaultUploadTTL, "lifetime of presigned direct-upload URLs")
|
|
c.Flags().StringVarP(&configPath, "config", "c", "", "JSON config file; explicit flags override its values")
|
|
c.Flags().StringVar(&projectsDB, "projects-db", "", "hub project registry file (default: $BDRIVE_HOME/projects.json)")
|
|
return c
|
|
}
|