2026-07-10 00:01:56 -07:00
|
|
|
package webapp
|
|
|
|
|
|
2026-08-03 16:20:51 +09:00
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"strings"
|
2026-08-11 04:18:53 +09:00
|
|
|
"time"
|
2026-08-03 16:20:51 +09:00
|
|
|
"unicode/utf8"
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-10 00:01:56 -07:00
|
|
|
// 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
|
2026-07-11 14:41:07 -07:00
|
|
|
Reads() ReadRepo
|
2026-08-11 04:18:53 +09:00
|
|
|
SessionReads() SessionReadRepo
|
2026-07-10 00:01:56 -07:00
|
|
|
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
|
2026-08-03 16:20:51 +09:00
|
|
|
// 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
|
2026-07-10 00:01:56 -07:00
|
|
|
}
|
2026-07-11 14:41:07 -07:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
2026-08-03 16:20:51 +09:00
|
|
|
|
2026-08-11 04:18:53 +09:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 16:20:51 +09:00
|
|
|
// ---- 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); 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)
|
|
|
|
|
}
|
2026-08-11 04:18:53 +09:00
|
|
|
|
|
|
|
|
func checkSessionRead(s SessionRead) error {
|
|
|
|
|
return storable(s.Project, s.Session, s.Path, s.Device)
|
|
|
|
|
}
|