mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Deleting a project no longer removes its registry row: Delete(id, by) marks it with Deleted/DeletedBy and every live-project read path skips tombstones, so the name is immediately reusable, no content route answers for one, and who-deleted-what-when stays queryable — the row is the audit record. GET /api/projects?deleted=1 lists tombstones through the same permission resolver as the live list (hub admins additionally see tombstones of deleted orgs, the only audience left once the memberships are gone). Each delete also writes an audit: log line and a project_deleted / org_deleted PostHog event through the existing Server.capture — ids only, a no-op unless analytics is configured. The SQL backend grows deleted/deleted_by columns via the idempotent addColumns migration, guarded: a rolled-back deleted column would resurrect every deleted project as live with its storage already purged. The file backend stores the whole row and needs nothing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
228 lines
8.0 KiB
Go
228 lines
8.0 KiB
Go
package webapp
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// MetaStore is the hub's metadata persistence, split into one typed repository
|
|
// per entity. It holds ONLY the control plane — accounts, tokens, projects,
|
|
// orgs, invites, shares, devices. File content and the append-only journals
|
|
// live in the object store and never touch this; ephemeral state (one-time
|
|
// login and device codes, rate-limit buckets) stays in memory.
|
|
//
|
|
// A deployment chooses the backend: `file` (JSON on disk, the zero-dependency
|
|
// default) or `sql` (SQLite locally, Postgres/Supabase in production). The
|
|
// service structs (BuiltinAuth, OrgDB, …) keep their in-memory maps, mutexes,
|
|
// and business logic and persist each change through these repos — so reads
|
|
// stay in memory and writes are a single record apiece, which every backend
|
|
// implements as one real row.
|
|
type MetaStore interface {
|
|
Accounts() AccountRepo
|
|
Projects() ProjectRepo
|
|
Orgs() OrgRepo
|
|
Shares() ShareRepo
|
|
Devices() DeviceRepo
|
|
Reads() ReadRepo
|
|
SessionReads() SessionReadRepo
|
|
Close() error
|
|
}
|
|
|
|
// AccountRepo persists accounts, device tokens, and the (singleton) signup
|
|
// policy. Load returns everything at open; every other method is one record.
|
|
type AccountRepo interface {
|
|
Load() (users []*authUser, tokens []authToken, policy *authPolicy, err error)
|
|
PutAccount(u *authUser) error
|
|
DeleteAccount(id string) error
|
|
PutToken(t authToken) error
|
|
DeleteToken(hash string) error
|
|
PutPolicy(p authPolicy) error
|
|
}
|
|
|
|
type ProjectRepo interface {
|
|
Load() ([]Project, error)
|
|
Put(p Project) error
|
|
Delete(id string) error
|
|
}
|
|
|
|
type OrgRepo interface {
|
|
Load() (orgs []Org, invites []OrgInvite, err error)
|
|
PutOrg(o Org) error
|
|
DeleteOrg(id string) error
|
|
PutInvite(i OrgInvite) error
|
|
DeleteInvite(token string) error
|
|
}
|
|
|
|
type ShareRepo interface {
|
|
Load() ([]Share, error)
|
|
Put(s Share) error
|
|
Delete(token string) error
|
|
}
|
|
|
|
type DeviceRepo interface {
|
|
Load() ([]DeviceInfo, error)
|
|
Put(d DeviceInfo) error
|
|
// Delete removes one account's row for one device id. Rows are keyed by
|
|
// (account, id), and this is how an offboarded account's hub-wide claim on
|
|
// a machine is released — see DeviceRegistry.Release.
|
|
Delete(user, id string) error
|
|
}
|
|
|
|
// ReadRepo persists read-telemetry buckets (see ReadStat). Unlike the other
|
|
// repos it is batch-oriented: reads are telemetry, and the ledger flushes many
|
|
// dirty buckets at once — one file rewrite / one SQL transaction per flush,
|
|
// not one write per bucket.
|
|
type ReadRepo interface {
|
|
Load() ([]ReadStat, error)
|
|
PutBatch(stats []ReadStat) error // upsert by (project, path, day, kind, actor)
|
|
DeleteBatch(keys []ReadStatKey) error
|
|
}
|
|
|
|
// SessionReadRepo persists which paths one agent session read (see
|
|
// SessionRead). Deliberately its OWN repo rather than a session column on
|
|
// read_stats: ReadLedger loads every read_stats row into one map at boot and
|
|
// ReadLedger.Heat linearly scans that whole map on every heat request,
|
|
// hub-wide — so multiplying its row count by session cardinality would slow
|
|
// the Dashboard for projects that never ran an agent. These rows never enter
|
|
// that map; they are queried by primary key and pruned by date.
|
|
type SessionReadRepo interface {
|
|
PutBatch(reads []SessionRead) error // upsert by (project, session, device, path)
|
|
ListBySession(project, session, device string) ([]SessionRead, error)
|
|
PruneBefore(t time.Time) error
|
|
}
|
|
|
|
// ---- cheap change detection ---------------------------------------------
|
|
|
|
// Versioned is the optional "has anything moved?" check on a repository: a
|
|
// token that changes whenever anything the repo stores changes.
|
|
//
|
|
// Every registry re-reads its whole store on every authorization read (see
|
|
// ProjectDB.refresh) — that is a correctness floor, not a cache, and it stays.
|
|
// This is the same read made cheap: one os.Stat, or one primary-key lookup,
|
|
// instead of a full JSON parse or nine unfiltered SELECTs on every
|
|
// authenticated request. It is NOT a TTL: a token that moved is always
|
|
// followed by the full re-read, so the staleness window rounds 12-14 closed
|
|
// stays closed.
|
|
//
|
|
// A repo that cannot answer — no implementation, or an error — is treated as
|
|
// changed, so the fallback is exactly the unconditional re-read that was
|
|
// always there. An implementation must never return an empty token.
|
|
type Versioned interface {
|
|
Version() (string, error)
|
|
}
|
|
|
|
// versionGate is the per-registry half of that check. It remembers the token
|
|
// of the last SUCCESSFUL load, so a load that failed never leaves the registry
|
|
// marked fresh. Callers hold the registry's own mutex — refresh() already does.
|
|
type versionGate struct {
|
|
token string
|
|
valid bool
|
|
}
|
|
|
|
// stale reports whether repo may have changed since the last successful load,
|
|
// and returns the token to record once that load succeeds.
|
|
func (g *versionGate) stale(repo any) (token string, stale bool) {
|
|
v, ok := repo.(Versioned)
|
|
if !ok {
|
|
return "", true
|
|
}
|
|
cur, err := v.Version()
|
|
if err != nil || cur == "" {
|
|
return "", true // can't tell → re-read, and record nothing
|
|
}
|
|
if g.valid && cur == g.token {
|
|
return cur, false
|
|
}
|
|
return cur, true
|
|
}
|
|
|
|
// fresh records the token of a load that succeeded.
|
|
func (g *versionGate) fresh(token string) {
|
|
if token != "" {
|
|
g.token, g.valid = token, true
|
|
}
|
|
}
|
|
|
|
// ---- what a metadata store will hold ------------------------------------
|
|
|
|
// storable refuses text that no metadata backend can hold faithfully, so all
|
|
// three agree on which requests succeed.
|
|
//
|
|
// The three disagreed on eighteen inputs. A NUL cannot go in a Postgres text
|
|
// column at all (SQLSTATE 22021), while sqlite keeps it and the file backend
|
|
// keeps it. Invalid UTF-8 is worse than a disagreement on the file backend —
|
|
// the default: encoding/json substitutes U+FFFD per bad byte and reports
|
|
// SUCCESS, so the running hub and its database hold different records, nothing
|
|
// is logged, and two inputs that differ in memory fold onto one key on disk.
|
|
//
|
|
// Refusing is the decision rather than storing verbatim, for two reasons. It
|
|
// is what the doors already enforce (printableOnly, hasControlChars,
|
|
// journal.SafePath), so there is one rule instead of three. And it means a hub
|
|
// cannot change what it accepts by changing its database — the property that
|
|
// let row 14 look clean for seven rounds while the backends diverged.
|
|
//
|
|
// It replaces the assertion in the retired TestSec_DB_NULBytesDoNotTruncateRecords
|
|
// ("a NUL must round-trip verbatim"), which Postgres cannot implement without
|
|
// moving this whole layer to bytea. See TestSec_DB_EveryBackendAgreesWhichTextIsStorable.
|
|
func storable(vals ...string) error {
|
|
for _, v := range vals {
|
|
if strings.IndexByte(v, 0) >= 0 {
|
|
return fmt.Errorf("metadata text may not contain a NUL byte: %q", v)
|
|
}
|
|
if !utf8.ValidString(v) {
|
|
return fmt.Errorf("metadata text must be valid UTF-8: %q", v)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// storableMap checks a map's keys and values — the grant and membership maps,
|
|
// where the KEY is the account an authorization decision keys on.
|
|
func storableMap(m map[string]string) error {
|
|
for k, v := range m {
|
|
if err := storable(k, v); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkAccount(u *authUser) error {
|
|
return storable(u.ID, u.Email, u.Name, u.Pass, u.Status)
|
|
}
|
|
|
|
func checkToken(t authToken) error { return storable(t.Hash, t.User, t.Device) }
|
|
|
|
func checkProject(p Project) error {
|
|
if err := storable(p.ID, p.Name, p.Org, p.Description, p.Icon,
|
|
p.Creator, p.Template, p.Default, p.DeletedBy); err != nil {
|
|
return err
|
|
}
|
|
return storableMap(p.Perms)
|
|
}
|
|
|
|
func checkOrg(o Org) error {
|
|
if err := storable(o.ID, o.Name); err != nil {
|
|
return err
|
|
}
|
|
return storableMap(o.Members)
|
|
}
|
|
|
|
func checkInvite(i OrgInvite) error { return storable(i.Token, i.Org, i.Creator) }
|
|
|
|
func checkShare(s Share) error { return storable(s.Token, s.Project, s.Path, s.Creator) }
|
|
|
|
func checkDevice(d DeviceInfo) error {
|
|
return storable(d.ID, d.Name, d.OS, d.User, d.IP)
|
|
}
|
|
|
|
func checkReadStat(s ReadStat) error {
|
|
return storable(s.Project, s.Path, s.Day, s.Kind, s.Actor)
|
|
}
|
|
|
|
func checkSessionRead(s SessionRead) error {
|
|
return storable(s.Project, s.Session, s.Path, s.Device)
|
|
}
|