Help us shape the future of Go! Take the 2026 Go Developer Survey

webapp

package
v0.18.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 27, 2026 License: AGPL-3.0 Imports: 68 Imported by: 0

Documentation

Overview

Package webapp serves the bdrive web server: a browsable web view of synced files (file tree reconstructed from the journals, rendered markdown, downloads), browser uploads, and — in hub mode — the sync API that lets storage-blind client devices sync whole projects through this server.

Two modes:

  • single-volume: Source is set (a DirSource for a plain folder, or a RemoteSource in tests); the classic viewer.
  • hub: Root + Projects are set; the server hosts many projects, each a volume stored under <root>/<project-id>/ in the object store, managed by a file-backed project registry.

The client — browser or syncing device — is deliberately told nothing about the storage: no remote URL, bucket, or credentials ever appear in an API response.

Index

Constants

View Source
const (
	RoleOwner  = "owner"
	RoleMember = "member"
)
View Source
const (
	PermNone  = "none"  // the project is hidden: absent from the list, 403 everywhere
	PermRead  = "read"  // browse, view, download, history, heat
	PermWrite = "write" // + upload, sync push, share links
	PermAdmin = "admin" // + rename, delete, edit this project's permissions
)
View Source
const (
	ReadKindHuman = "human"
	ReadKindAgent = "agent"
	ReadKindShare = "share"
)

Read kinds.

View Source
const (

	// 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
)
View Source
const DefaultAnalyticsHost = "https://us.i.posthog.com"

DefaultAnalyticsHost is PostHog's US cloud ingestion host.

View Source
const DefaultInviteTTL = 7 * 24 * time.Hour

DefaultInviteTTL bounds invite links that don't ask for an expiry.

View Source
const DefaultShareRPM = 120

DefaultShareRPM is the per-IP sustained rate on /s/* when the config doesn't say otherwise.

View Source
const DefaultUploadTTL = 15 * time.Minute

DefaultUploadTTL is used when UploadConfig.TTL is unset: long enough for a slow upload, short enough that a leaked URL goes stale quickly.

View Source
const RoleApp = "app"

RoleApp is an app's role in its own organization: it can hold explicit grants there and nothing else.

Variables

View Source
var ErrManagedElsewhere = errors.New("this organization is managed outside this hub")

ErrManagedElsewhere is returned by a directory that does not own its organizations. Handlers turn it into 409 plus the org's ManageURL — the request was well-formed, it is the state of the world that makes it wrong.

Functions

func APIRoutes added in v0.16.0

func APIRoutes() []string

APIRoutes returns every pattern Handler registers, in registration order. A zero Server is enough: registration builds closures and never calls a handler, and every provider-dependent block is nil-guarded.

func MigrateOrgs added in v0.3.0

func MigrateOrgs(projects *ProjectDB, orgs orgWriter, accounts []User) error

func NewPostgresRelay added in v0.16.0

func NewPostgresRelay(db *sql.DB, dsn string) (*pgRelay, error)

NewPostgresRelay carries change frames between hub processes over LISTEN/NOTIFY. db is the existing pool (used for NOTIFY); dsn opens the dedicated connection LISTEN needs.

func RenderMarkdown

func RenderMarkdown(src []byte) (string, error)

RenderMarkdown converts markdown to HTML (GFM + wikilinks). Raw HTML in the source is escaped by goldmark's safe default. A leading YAML frontmatter block renders as a small key/value table instead of the broken thematic-break soup goldmark would make of it.

This is the PUBLIC SHARE PAGE's renderer (shares.go) and its output is what every link ever minted serves — the table stays. The viewer uses RenderMarkdownPairs instead, which hands the frontmatter to the client as data so it can live in a side panel.

func SQLiteDSN added in v0.18.1

func SQLiteDSN(dsn string) string

SQLiteDSN is how every pool on a hub's SQLite file opens it for writing. Several pools share one file (the metadata store, the data index, and whatever a deployment opens beside them), and SQLite admits one writer at a time:

  • busy_timeout: a writer that meets another's lock waits for it, where the default (zero) fails SQLITE_BUSY at once;
  • WAL: a reader runs beside a writer rather than behind it;
  • _txlock=immediate: a transaction takes the write lock when it begins. A deferred one that reads before it writes fails SQLITE_BUSY as soon as another writer has committed since its read, and no timeout helps it.

Each is a per-connection setting, so it rides on the DSN and reaches every connection a pool opens; a PRAGMA run once reaches only the connection it ran on. A setting the DSN already names is left as the operator wrote it.

Types

type AccountApprover added in v0.9.0

type AccountApprover interface {
	PendingUsers() []User
	Approve(id string) error
	Deny(id string) error
	SetPolicy(requireVerification, requireApproval bool) error
	// Policy reports the signup gates as configured. The provider assembles
	// it, so the hub never reaches into provider fields to render the page.
	Policy() SignupPolicy
}

AccountApprover is the optional half of account administration: signup policy and the approval queue behind /api/admin/*. A provider whose accounts live in an external identity system does not implement it, and those routes say so (503) rather than pretending the queue is empty.

type AccountRepo added in v0.3.0

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
}

AccountRepo persists accounts, device tokens, and the (singleton) signup policy. Load returns everything at open; every other method is one record.

type AnalyticsConfig added in v0.13.0

type AnalyticsConfig struct {
	Key  string // PostHog project key; empty disables analytics entirely
	Host string // PostHog API host; empty means DefaultAnalyticsHost
}

AnalyticsConfig points the frontend at a PostHog project. The key is a public write-only project token, not a credential — it is served to signed- out visitors too, because the app shell loads before login.

func (AnalyticsConfig) Endpoint added in v0.13.0

func (a AnalyticsConfig) Endpoint() string

Endpoint is Host with the default applied. Exported because the same config drives more than the app shell in a managed deployment (the cloud module's marketing pages render their own loader from it).

type App added in v0.18.1

type App struct {
	ID      string    `json:"id"`
	Org     string    `json:"org"`
	Name    string    `json:"name"`
	Creator string    `json:"creator,omitempty"`
	Created time.Time `json:"created"`
}

App is one app principal.

type AppDB added in v0.18.1

type AppDB struct {
	// contains filtered or unexported fields
}

AppDB is the app registry: in memory, re-read when the store has moved, so a key revoked by another hub process stops working here too.

func NewAppDB added in v0.18.1

func NewAppDB(repo AppRepo) (*AppDB, error)

func OpenAppDB added in v0.18.1

func OpenAppDB(path string) (*AppDB, error)

OpenAppDB loads the file-backed registry at path.

func (*AppDB) Authenticate added in v0.18.1

func (db *AppDB) Authenticate(token string) (App, AppKey, bool)

Authenticate resolves a key to its app, re-reading the registry first so a revocation anywhere is honoured, and records the use at most once a minute.

func (*AppDB) Create added in v0.18.1

func (db *AppDB) Create(org, name, creator string) (App, error)

func (*AppDB) Delete added in v0.18.1

func (db *AppDB) Delete(id string) error

Delete removes an app and every key it holds.

func (*AppDB) Get added in v0.18.1

func (db *AppDB) Get(id string) (App, bool)

Get returns an app.

func (*AppDB) List added in v0.18.1

func (db *AppDB) List(org string) ([]App, map[string][]AppKey)

List returns an organization's apps and each app's keys, oldest first.

func (*AppDB) Mint added in v0.18.1

func (db *AppDB) Mint(app, by string) (string, AppKey, error)

Mint issues a key and returns its plaintext — the only time it exists.

func (*AppDB) Revoke added in v0.18.1

func (db *AppDB) Revoke(app, id string) error

Revoke deletes one key of an app.

type AppFolderGrant added in v0.18.1

type AppFolderGrant struct {
	Prefix string `json:"prefix"` // normalized, slash-terminated
	App    string `json:"app"`    // app:<id>
	Level  string `json:"level"`
	By     string `json:"by,omitempty"`
}

AppFolderGrant is one app's level on one folder, and who gave it.

type AppKey added in v0.18.1

type AppKey struct {
	ID       string    `json:"id"`
	App      string    `json:"app"`
	Digest   string    `json:"digest"`
	Hint     string    `json:"hint"`
	Created  time.Time `json:"created"`
	LastUsed time.Time `json:"last_used,omitzero"`
	// MintedBy is the member who minted the key. It works only while they
	// could still mint it (Server.keyStands); empty on a key minted before
	// keys were attributed, which only an owner could do.
	MintedBy string `json:"minted_by,omitempty"`
}

AppKey is one of an app's keys, by digest. Hint is the last four characters of the key, so its owner can tell two keys apart.

type AppRepo added in v0.18.1

type AppRepo interface {
	Load() ([]App, []AppKey, error)
	PutApp(a App) error
	DeleteApp(id string) error // and its keys
	PutKey(k AppKey) error
	// TouchKey records a key's last use only while the key still exists: a
	// use that races a revocation must not write the key back.
	TouchKey(id string, at time.Time) error
	DeleteKey(id string) error
}

AppRepo persists apps — org-owned principals for servers — and their keys. A key is stored as its digest; the plaintext exists once, in the answer that minted it.

type AuthProvider

type AuthProvider interface {
	// CLILoginPath is the page `bdrive login` opens in a browser. The CLI
	// appends ?redirect=http://127.0.0.1:<port>/callback&state=<nonce>.
	CLILoginPath() string
	// Authenticate resolves the request's Bearer token or session cookie.
	Authenticate(r *http.Request) (User, bool)
	// Register mounts the provider's own pages and endpoints (/auth/*,
	// /api/auth/*) on the server mux.
	Register(mux *http.ServeMux)
	// Accounts lists every account the provider knows, oldest first. Startup
	// tasks (the org migration) need it, and both implementations already had
	// it — declaring it here stops callers reaching for a concrete type.
	Accounts() []User
	// UseDeviceBinder hands the provider the hub's device-binding hook. The
	// provider MUST call it at every point it mints a CLI token, before the
	// token is handed over, and MUST refuse the login if it returns an error.
	//
	// This is on the interface — a breaking change for an out-of-tree provider,
	// on purpose — because it is a PRECONDITION of a gate the hub enforces for
	// every provider. store.go's ownJournal refuses a journal write unless the
	// device id is bound to the caller's account, and DeviceRegistry.Bind is the
	// only thing that binds. That hook used to be a field on BuiltinAuth, wired
	// behind `if a, ok := s.Auth.(*BuiltinAuth); ok` — so on a hub running any
	// other provider nothing ever called it, no device was ever bound, and EVERY
	// journal push 403'd forever while login, permissions and blob uploads all
	// looked healthy. A gate whose enabler is wired to one concrete type is a
	// gate that is enforced further than it can be satisfied; declaring it here
	// is what makes a provider that ignores it fail to compile instead.
	//
	// The hub cannot do this for the provider. Binding must be reachable only
	// from a completed authentication — a device token that could reach a bind
	// would let a stolen credential squat a teammate's id — and Authenticate
	// reports only WHO a request is, never which credential class it presented.
	// Only the provider knows it is minting, so only the provider can bind.
	UseDeviceBinder(bind DeviceBinder)
}

AuthProvider is the seam between the server and an identity system.

type Brander added in v0.9.0

type Brander interface{ Branding() string }

Brander is the optional hub-name half: a provider that renders its own sign-in pages knows what to call this hub.

type BuiltinAuth

type BuiltinAuth struct {
	AllowSignup bool
	Mail        *Mailer // nil → reset links go to the server log

	// Public-URL signup gating (all optional; set after Open). A hub reachable
	// from the internet should use at least one of these.
	AllowedDomains      []string        // if non-empty, signup email domain must match one
	RequireVerification bool            // new accounts must click an email link before activation
	RequireApproval     bool            // new accounts wait for an admin to approve them
	Admins              map[string]bool // hub admins (lowercase emails): approve users, govern shares
	Brand               string          // optional name shown on the sign-in page

	// BaseURL is the hub's public origin ("https://drive.acme.com"). Links the
	// hub MAILS are built from it. Empty → the hub has no origin it can trust
	// and mailed links stop being absolute as soon as two requests disagree
	// about the host; see mailBaseURL.
	BaseURL string

	// InviteValid, when set, reports whether a token is a live org invite.
	// It lets an invite link bootstrap an account on an invite-only hub
	// (AllowSignup false) — the one path in without self-signup. Wired to
	// OrgDB.ValidInvite by the server. Nil → no invite-based signup.
	InviteValid func(token string) bool

	// Offboard, when set, is called with the address of an account that has
	// just been removed. Everything downstream of removal is keyed by email
	// (org role, project grant, share liveness), so without it the grants
	// outlive the account. Wired to Server.offboard by the server.
	Offboard func(email string)

	// BindDevice, when set, records that a device id belongs to an account, at
	// the moment a token is minted for it. It is the ONLY way an ownership row
	// is created for an id that has never synced — see DeviceRegistry.Bind for
	// why first-claim-on-write could not be. Installed by UseDeviceBinder.
	BindDevice DeviceBinder
	// contains filtered or unexported fields
}

BuiltinAuth is the open-source identity provider: email + password + name accounts and long-lived device tokens, persisted in one JSON file (loaded at open, rewritten atomically on every change — same discipline as the project registry). It owns the /auth/* pages the browser sees and the /api/auth/* endpoints the CLI uses.

func NewBuiltinAuth added in v0.3.0

func NewBuiltinAuth(store AccountRepo, allowSignup bool, mail *Mailer) (*BuiltinAuth, error)

NewBuiltinAuth builds the account service over an AccountRepo, loading its accounts, tokens, and persisted policy.

func OpenBuiltinAuth

func OpenBuiltinAuth(path string, allowSignup bool, mail *Mailer) (*BuiltinAuth, error)

OpenBuiltinAuth loads (or starts) the file-backed account registry at path.

func (*BuiltinAuth) Accounts added in v0.3.0

func (a *BuiltinAuth) Accounts() []User

Accounts returns every account, oldest first (used by the org migration to pick the default org's owner).

func (*BuiltinAuth) Approve added in v0.3.0

func (a *BuiltinAuth) Approve(id string) error

Approve activates a pending account.

func (*BuiltinAuth) Authenticate

func (a *BuiltinAuth) Authenticate(r *http.Request) (User, bool)

func (*BuiltinAuth) Branding added in v0.9.0

func (a *BuiltinAuth) Branding() string

Branding is the hub name this provider renders on its own pages.

func (*BuiltinAuth) CLILoginPath

func (a *BuiltinAuth) CLILoginPath() string

func (*BuiltinAuth) Deny added in v0.3.0

func (a *BuiltinAuth) Deny(id string) error

Deny removes a pending account.

func (*BuiltinAuth) PendingUsers added in v0.3.0

func (a *BuiltinAuth) PendingUsers() []User

PendingUsers lists accounts awaiting admin approval, oldest first.

func (*BuiltinAuth) Policy added in v0.9.0

func (a *BuiltinAuth) Policy() SignupPolicy

Policy reports this provider's signup gates (webapp.AccountApprover). The provider assembles it so the hub never reaches into these fields itself.

func (*BuiltinAuth) Register

func (a *BuiltinAuth) Register(mux *http.ServeMux)

func (*BuiltinAuth) Seniority added in v0.15.0

func (a *BuiltinAuth) Seniority() []string

Seniority is the oldest-first account order, and it is EMPTY when this hub holds no evidence of age at all.

OrgDB.heir breaks a Joined tie on it, and its own doc comment says "with no seniority available there is NO evidence, and the answer is nobody: an ownerless org is a repair a hub admin makes deliberately, while an arbitrary heir is a privilege grant nobody asked for". Handing back a merely deterministic order would satisfy the letter and not the sentence — it would promote the same arbitrary member every time, which is round 8's finding with a different arbitrary key. Rows with no Created stamp are dropped, so an upgraded hub that recorded nothing says nothing.

func (*BuiltinAuth) SetPolicy added in v0.3.0

func (a *BuiltinAuth) SetPolicy(requireVerification, requireApproval bool) error

SetPolicy updates the tunable gating toggles and persists them.

The prospective policy goes through the startup validator FIRST. The hub starts legally as {allow_signup:true, require_approval:true}; one admin POST used to remove the only gate, and because SetPolicy persists, the hub then survived a restart the same binary refuses to perform — CLAUDE.md states the guarantee as "refuses an ungated open hub rather than silently leaving the door open". Here rather than in handleAdminPolicy because the handler is one caller of this and a second caller would arrive without the check; the handler only had the mailer half of the rule anyway.

func (*BuiltinAuth) UseDeviceBinder added in v0.16.0

func (a *BuiltinAuth) UseDeviceBinder(bind DeviceBinder)

UseDeviceBinder installs the hub's binding hook. finishLogin — the one place this provider mints a CLI token, reached by all three flows — calls it before it issues anything.

func (*BuiltinAuth) UsePending added in v0.16.1

func (a *BuiltinAuth) UsePending(r PendingRepo)

UsePending gives this provider's CLI sign-in flow a shared, durable home

for pending grants, so `bdrive login` can span two hub processes.

On the provider rather than reaching through to a.cli: CLIAuth is an
internal collaborator, and a caller that has a MetaStore should be able to
hand it over without knowing that. Without this the flow keeps its
in-memory store and behaves exactly as it always has, which is right for a
single-instance hub.

func (*BuiltinAuth) ValidateSignupPolicy added in v0.3.0

func (a *BuiltinAuth) ValidateSignupPolicy() error

ValidateSignupPolicy rejects incoherent signup configurations at startup so a hub is never accidentally left open to fake-email signups. The three supported postures are: invite-only (AllowSignup false — the default), approval-gated, and domain-restricted with email verification.

  • Open self-signup must carry at least one gate (allowed domains, admin approval, or email verification). Without one, anyone can register any address — the exact hole this guards.
  • Email verification needs a mailer: without SMTP the link only reaches the server log, so it can't actually gate real users.

type CLIAuth added in v0.14.0

type CLIAuth struct {
	// contains filtered or unexported fields
}

CLIAuth is the CLI-facing half of signing in, whole: the loopback browser flow (/auth/cli → one-time code → /api/auth/exchange), the headless device flow (/api/auth/device/start → approval link → /api/auth/device/poll), and the approval page both of them show.

It is its own type rather than methods on an AuthProvider because this half of the protocol is identical no matter where the accounts live: `bdrive login` POSTs fixed paths and expects fixed JSON, so a provider differs only in who the browser session is and how a device token is minted — the two hooks below. The managed hub's provider used to carry its own copy of all of this, and the copy drifted: months after the OSS flow moved to a single approval link that names the device, the copy was still printing a four-byte code to retype into a text box. One implementation, every provider, nothing to keep in sync.

func NewCLIAuth added in v0.14.0

func NewCLIAuth(session func(*http.Request) (User, bool), issue func(w http.ResponseWriter, r *http.Request, userID, device string)) *CLIAuth

NewCLIAuth wires the two provider-specific pieces. session resolves the browser session — cookie only, never a Bearer token, or a device token could approve the next device. issue writes the CLI's {token, user} response for an approved grant.

func (*CLIAuth) Register added in v0.14.0

func (c *CLIAuth) Register(mux *http.ServeMux)

Register mounts the paths `bdrive login` knows. They are fixed: an older CLI on a newer hub must still find them.

func (*CLIAuth) UsePending added in v0.16.0

func (c *CLIAuth) UsePending(r PendingRepo)

UsePending gives this CLIAuth a durable, shared home for pending sign-ins. Without it grants live in memory and a login cannot span two processes — which is correct for a single-instance hub and fatal behind a load balancer.

type CommentAnchor added in v0.17.0

type CommentAnchor struct {
	// Quote is the text the reader selected, as rendered.
	Quote string `json:"quote,omitempty"`
	// Prefix and Suffix are the rendered text just before and after Quote,
	// for telling apart two places that say the same thing.
	Prefix string `json:"prefix,omitempty"`
	Suffix string `json:"suffix,omitempty"`
	// Line is the 1-based source line, when the viewer showed the source
	// itself (plain text, code) and therefore knew it.
	Line int `json:"line,omitempty"`
	// Element describes a picked element on a rendered HTML page, as a CSS
	// selector path. Set for a pin on something with no text to quote.
	Element string `json:"element,omitempty"`
}

CommentAnchor is where on the page a thread was started. Every field is optional: a thread with no anchor is about the whole file.

type CommentMessage added in v0.17.0

type CommentMessage struct {
	ID     string    `json:"id"`
	Author string    `json:"author,omitempty"`
	Name   string    `json:"name,omitempty"`
	Body   string    `json:"body"`
	Time   time.Time `json:"time"`
}

type CommentThread added in v0.17.0

type CommentThread struct {
	ID string `json:"id"`
	// Path is where the file is NOW. A thread follows its file across renames
	// the way a share link does (resolveShare), so feedback left before a
	// move is still found on the moved file.
	Path         string           `json:"path"`
	Anchor       CommentAnchor    `json:"anchor"`
	Sha          string           `json:"sha,omitempty"` // the file version commented on
	Author       string           `json:"author,omitempty"`
	Name         string           `json:"name,omitempty"`
	Created      time.Time        `json:"created"`
	Updated      time.Time        `json:"updated"`
	Messages     []CommentMessage `json:"messages"`
	Resolved     bool             `json:"resolved"`
	ResolvedBy   string           `json:"resolved_by,omitempty"`
	ResolvedName string           `json:"resolved_name,omitempty"`
	ResolvedAt   *time.Time       `json:"resolved_at,omitempty"`
	// Outdated: the file has changed since the comment was left, so the
	// quote may already be gone. Missing: the file itself is gone.
	Outdated bool `json:"outdated,omitempty"`
	Missing  bool `json:"missing,omitempty"`
	// contains filtered or unexported fields
}

type DataIndex added in v0.18.1

type DataIndex struct {
	// contains filtered or unexported fields
}

DataIndex is the hub's index of project data: one row per JSON file, and

per Markdown file's frontmatter, in every project whose Data setting is on.

Files stay the truth. A row is derived from two things the hub already has:
the fold (which version of each path is current) and the blob (what that
version says). Rows live in the hub's SQL database, not in process memory,
so every hub process answers a query the same way and a process that has
just started answers it without folding a journal or fetching a blob.

Each row carries `win`, the replay-order key of the op that produced it
(winKey). Every write is conditional on the stored key being older, so an
applier holding an older fold — a slower process, a background pass that
lost a race with the write that came after it — can never put back what a
newer fold replaced. A deletion is a row too (deleted, keyed on the delete
op) for the same reason: without it an older fold would put the file back.

Queries run on their own small, read-only pool with a statement timeout, so
a slow aggregate waits on its own connections and never on the ones sign-in
and sync use.

func OpenDataIndex added in v0.18.1

func OpenDataIndex(driver, dsn string) (*DataIndex, error)

OpenDataIndex opens (and migrates) the data index. driver and dsn are the metadata database's own ("sqlite" or "pgx"), or "sqlite" and a file path for a hub whose metadata is files.

func (*DataIndex) Close added in v0.18.1

func (x *DataIndex) Close() error

func (*DataIndex) SetMaxConns added in v0.18.1

func (x *DataIndex) SetMaxConns(applier, queries int)

SetMaxConns bounds the index's two pools: the applier's and the queries'. The defaults (2 and 4) assume a database with room to spare; a deployment sharing a small one — a db-f1-micro allows about 25 connections for every pool in the process — sizes them to its own budget.

type DeviceBinder added in v0.16.0

type DeviceBinder func(email string, r *http.Request) error

DeviceBinder records that the device identified by the request's X-Bdrive-Device header belongs to email. It returns an error when the id is already another account's, which a provider must surface as a failed login rather than a token that cannot push.

type DeviceInfo

type DeviceInfo struct {
	ID   string `json:"id"`
	Name string `json:"name,omitempty"`
	OS   string `json:"os,omitempty"`
	User string `json:"user,omitempty"` // account email last seen using this device
	IP   string `json:"ip,omitempty"`   // as observed by the server
	// FirstSeen is when this account was first observed syncing under this id.
	// It is the only ownership fact the hub actually holds: a row is created by
	// the caller's own header, so "does this account have a row" is a question
	// the asking request already answered for itself, while "who was here
	// first" is something a later caller cannot manufacture.
	FirstSeen time.Time `json:"first_seen,omitzero"`
	LastSeen  time.Time `json:"last_seen"`
}

DeviceInfo is what the server knows about one syncing device: self-reported name/OS (headers sent by the client), plus what the server itself observed (public IP of the last push, last activity, the signed-in account). History joins ops against this registry so ops stay small — but it reports only id/name/os (historyDevice, history.go): the IP is recorded here, not repeated to every project member on every change.

type DeviceRegistry

type DeviceRegistry struct {
	// contains filtered or unexported fields
}

DeviceRegistry is the in-memory device table over a MetaStore DeviceRepo.

func NewDeviceRegistry added in v0.3.0

func NewDeviceRegistry(repo DeviceRepo) (*DeviceRegistry, error)

NewDeviceRegistry builds the registry over a repo, loading its contents.

func OpenDeviceRegistry

func OpenDeviceRegistry(path string) (*DeviceRegistry, error)

OpenDeviceRegistry loads the file-backed registry at path.

func (*DeviceRegistry) AnyOwned added in v0.16.0

func (r *DeviceRegistry) AnyOwned() bool

AnyOwned reports whether this hub holds a single device row with an account on it. It is a diagnostic, not a gate: a hub whose auth provider never calls the binder can never answer yes, and that is the one observable difference between "your device is not registered" (fix it by signing in) and "no device on this hub is registered, and none can be" (fix the provider). See Server.ownJournal, which turns a no into a line in the operator's log.

func (*DeviceRegistry) Bind added in v0.15.0

func (r *DeviceRegistry) Bind(user string, d DeviceInfo, visible func(owner string) bool) error

Bind records that a device id belongs to an account, and is the ONLY way an ownership row comes into existence for an id that has never synced. It is called at token issuance (`bdrive login`, the device-code flow, and the login `bdrive init` runs inside itself), where the hub has just authenticated the account and the machine is the one asking.

This exists because the alternative was a race nobody could win. Ownership used to be minted by the first authorized journal PUT, admitted through `!known && journalNames(dev, ops)` — a check that reads a field the writer itself writes. A device that syncs with READ permission can never reach that door, so its id stayed unclaimed hub-wide forever and the first member with write on any project took it: permanently, with the victim's ops attributed to her device in History, and no remedy but abandoning device.json. Two hacker rounds found it from opposite sides and their tests were mutually unsatisfiable while first-claim-on-write was the only way a binding existed.

Claim-or-refuse is one critical section on purpose: two logins racing for one id must not both believe they won.

visible reports whether the conflicting owner is somebody the caller can already see on this hub, and it decides which of THREE outcomes a conflict gets. OwnerOf is deliberately hub-wide, so turning its answer into a status code makes the login a hub-wide device-existence oracle — the class round 3 closed for History, round 4 for the registry join and round 5 for /store/sign, arriving at a fourth door. So:

  • no conflicting row: bind.
  • conflict with an owner the caller can already see (same org): refuse, in words. Nothing is disclosed that the org's own surfaces do not disclose, and the machine learns why its sign-in did not take.
  • conflict with an owner the caller cannot see: bind NOTHING and succeed. The token is real, no ownership row is created either way, and the push door already answers "owned by someone else" and "owned by nobody" with the same 403 — so this loses no defence and answers no question.

func (*DeviceRegistry) Get

func (r *DeviceRegistry) Get(id string) (DeviceInfo, bool)

Get returns the most recently observed row for an id, whoever owns it. It is an unscoped display lookup: anything that serves the result to a project must use LookupIn instead, or it hands one org's device metadata to another.

func (*DeviceRegistry) LookupIn added in v0.15.0

func (r *DeviceRegistry) LookupIn(id string, allowed func(user string) bool) (DeviceInfo, bool)

LookupIn returns the row for an id whose owner passes allowed — the join every per-project surface uses, so a device belonging to an account outside the project's org resolves to nothing instead of leaking its machine name and OS (and confirming that the id exists at all).

When several accounts hold rows for one id, the FIRST claim wins. Picking the most recently observed row instead meant the second account to name an id decided what the whole project sees: one ordinary store request relabels a peer's device in History and in /heat?by=device, which is the forgery the per-account rekey was supposed to end.

func (*DeviceRegistry) MayActAs added in v0.15.0

func (r *DeviceRegistry) MayActAs(user, id string) bool

MayActAs reports whether an account may name a device id as itself: yes if this account has been seen syncing under it, and yes if nobody else has — an id nobody has claimed is the ordinary case of a device whose telemetry arrives before its first push, and refusing it would mean read heat never starts. What it refuses is naming a device another account is syncing.

This is the permissive question, and it is the right one for telemetry: a squatted id must still count its real owner's reads. Anything that WRITES on the strength of a device identity asks OwnerOf instead.

func (*DeviceRegistry) Observe

func (r *DeviceRegistry) Observe(d DeviceInfo)

Observe merges what a request revealed about a device, into the row owned by the account that made it. Disk writes are throttled: identity changes persist immediately, bare last-seen bumps at most once a minute.

The repo keeps one row per id (its primary key), so what survives a restart is the most recent observation. That is a display cache, not the authority: MayActAs reads the in-memory rows, and a device re-registers on its very next sync cycle.

func (*DeviceRegistry) OwnerOf added in v0.15.0

func (r *DeviceRegistry) OwnerOf(id string) (owner string, known bool)

OwnerOf resolves who a device id belongs to, hub-wide: the account whose row for it was created first. It is the WRITE gate's resolver (ownJournal), and it differs from LookupIn — the display join — in the two ways that made round 4's binding fail:

  • it is not scoped to an org. A claim that disappears when its owner is offboarded hands her journal to whoever is left in the org, and History keeps crediting her.
  • a row with no owner (a pre-accounts devices.json, or an auth-less observation) claims nothing. Round 4 read "no owner" as "no objection", so on every upgraded hub the binding was off for exactly the established devices.

It returns the owning account and whether the hub has ever seen the id at all. The two differ, and the difference is a hole round 4 fell into: a row with no owner means a device exists whose account is unknown (known=true, owner=""), which is a reason to refuse, not to wave through.

func (*DeviceRegistry) Release added in v0.15.0

func (r *DeviceRegistry) Release(user string)

Release drops every device row an account owns, in memory and on disk. It is the device half of offboarding, and it is not bookkeeping: OwnerOf is the WRITE gate (store.go ownJournal) and Bind refuses an id another account claims, so a claim that outlives its account is a claim nobody can ever dislodge.

The offboarding scenario IS "the laptop goes to the next hire", and without this that hire is permanently locked out in the least debuggable way: the departed account no longer shares an org with anybody, so Bind's invisible-conflict arm binds NOTHING and lets the login SUCCEED — every later push then 403s telling the user to run `bdrive login`, which is what she just did. The mirror case is worse: re-create the address and the new account silently inherits the departed employee's device row and write access to that device's journal.

A row the store refuses to delete stays in memory. Reporting a release that isn't one is the widening direction — the claim comes back at the next restart while the hub has already handed the id to somebody else.

type DeviceRepo added in v0.3.0

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
}

type DirSource

type DirSource struct {
	Root string
}

DirSource serves a plain local folder straight from disk — no bdrive remote or volume needed. Meant for debugging the webapp (and as a quick local markdown browser): the tree reflects the folder live, provenance is just file mtimes, and content streams from the filesystem.

func (*DirSource) Files

func (d *DirSource) Files(_ context.Context) (map[string]FileInfo, error)

func (*DirSource) Open

func (d *DirSource) Open(_ context.Context, path string, fi FileInfo) (io.ReadCloser, error)

Open streams a file from disk. Paths are only ever snapshot map keys (produced by Files above), so they cannot escape Root.

A symlink is never opened: os.Open follows it, and a link inside the folder pointing at ~/.ssh/id_rsa would then serve the key to anyone who can see the tree. The listing marks a link (fi.Link) and Lstat re-checks the disk, since a link placed after the last listing has no FileInfo saying so. This is the one door every reader goes through — viewer, download, render, shares, MCP, the co-editing room — so the refusal lives here and nowhere else.

func (*DirSource) Upload

func (d *DirSource) Upload(_ context.Context, p string, src io.Reader, _ int64, _ User, _ string) error

Upload writes the file atomically under Root. There is no journal here; on a mounted folder the daemon scans, journals, and syncs it like any local edit.

type DirectUploader

type DirectUploader interface {
	Uploader
	SignBlobPut(ctx context.Context, blob string, size int64, ttl time.Duration) (*remote.SignedPut, error)
	// BlobSize reports the stored size of a blob and whether it is there at
	// all. Size comes from storage, never from the caller: in direct mode the
	// server never sees the bytes, so this is the only true byte count it can
	// quota-check and journal.
	BlobSize(ctx context.Context, blob string) (int64, bool, error)
	// note rides along on the journaled op — "" for an ordinary upload,
	// "restore <path>@<sha8>" when the write is a restore.
	Commit(ctx context.Context, path, blob string, size int64, who User, note string) error
}

DirectUploader is additionally implemented by sources whose storage can accept presigned direct uploads.

type Directory added in v0.9.0

type Directory interface {
	// ---- reads (request path) ----
	Role(orgID, email string) string
	Get(orgID string) (Org, bool)
	OrgsFor(email string) []Org
	ListInvites(orgID string) []OrgInvite
	ValidInvite(token string) bool
	// ManageURL is where this org is administered: a path within this hub
	// when it owns its orgs, an external page when it does not. The client
	// follows it and never has to know which kind of hub it is talking to.
	ManageURL(orgID string) string

	// ---- writes (ErrManagedElsewhere when the directory is read-only) ----
	Create(name, ownerEmail string) (Org, error)
	Rename(orgID, name string) error
	AddMember(orgID, email, role string) error
	SetRole(orgID, email, role string) error
	RemoveMember(orgID, email string) error
	CreateInvite(orgID, creator string, ttl time.Duration) (OrgInvite, error)
	RevokeInvite(token string) bool
	Redeem(token string) (OrgInvite, bool)
	RecordInviteUse(token string)
}

Directory is where a hub's organizations live. The built-in one is this package's OrgDB (LocalDirectory); a deployment whose users, orgs and memberships are owned by an external identity system supplies its own, alongside its AuthProvider.

Two rules shape this interface:

Reads are on the request path. Role is called for every project request, including the /store/* sync endpoints a device hits every few seconds with a device token that carries no identity claims. An implementation backed by a remote system therefore has to answer Role from a local cache, and that cache is the implementation's business — the hub does not keep one, does not refresh one, and must never be written to from the side. (It used to be: the hub owned the mirror and the auth provider poked at it, which is how a hub-invented org that the identity system had never heard of could exist.)

Writes are optional. A directory that does not own its data returns ErrManagedElsewhere and the handler answers 409 with ManageURL, so the hub never needs to know WHY it cannot write — only where the user should go.

type FileInfo

type FileInfo struct {
	Blob string
	Size int64
	Time time.Time
	// Link is the target of a symbolic link, verbatim. When set the entry has
	// no Blob and no content: the viewer shows the link and its target and
	// never resolves it, so a link pointing outside the project serves nothing.
	Link string
	// User/UserName are the signed-in account behind the change; Author is
	// the git/OS identity an offline device falls back to. History renders
	// the account and falls back to Author, so the viewer needs all three
	// to give the same answer — see whoChanged() in the frontend.
	User     string
	UserName string
	Author   string
	Device   string
	// The rest of the replay-order key of the op that produced this state
	// (journal.Less), beside Time, Blob and Size above. A derived index keys
	// its rows on it (dataindex.go) so that a fold older than the one a row
	// came from can never write over it. Set by foldFiles; zero elsewhere.
	Lamport  int64
	Seq      int64
	DeviceID string
	Mode     uint32
}

FileInfo is the resolved state of one path: content identity (Blob doubles as the ETag), plus provenance where the source knows it.

type FolderRule added in v0.16.0

type FolderRule struct {
	Prefix  string            `json:"prefix"`            // normalized, always slash-terminated
	Default string            `json:"default,omitempty"` // "" = inherit the project level
	Perms   map[string]string `json:"perms,omitempty"`   // lowercase email → level
	// GrantedBy names the member who gave each app grant in Perms; see
	// Project.GrantedBy.
	GrantedBy map[string]string `json:"granted_by,omitempty"`
}

FolderRule restricts one prefix. It mirrors Project.Default + Project.Perms exactly one level down, including the empty-string sentinel: an empty Default means "say nothing, inherit the project level", which is how a rule can name a few accounts without changing what everyone else gets.

type FrontmatterPair added in v0.16.0

type FrontmatterPair struct {
	Key   string `json:"key"`
	Value string `json:"value"`
	Code  bool   `json:"code,omitempty"`
}

FrontmatterPair is one key/value row of a document's YAML frontmatter, in author order. Value is plain text, never markup; Code marks the values the table renders inside <code> (anything nested).

func RenderMarkdownPairs added in v0.16.0

func RenderMarkdownPairs(src []byte) ([]FrontmatterPair, string, error)

RenderMarkdownPairs is RenderMarkdown for the viewer: the frontmatter comes back as ordered key/value data and the HTML is body-only. Values are raw text (never markup) — the client escapes them by rendering them as text nodes.

type HeatEntry added in v0.4.0

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"`
}

HeatEntry is the per-path aggregate the heat API returns. Counts only — never identities.

type HistoryEntry

type HistoryEntry struct {
	Time string `json:"time"`
	Kind string `json:"kind"` // add | edit | delete
	Path string `json:"path"`
	Size int64  `json:"size,omitempty"`
	Blob string `json:"blob,omitempty"` // sha256; fetch via the blob endpoint
	// Prev is the content this path last had before the op — across a
	// delete too, so a delete names what it removed and a re-add what it
	// replaced. Absent when the path never had content before, and on a
	// delete of a path that was already gone (e.g. two devices deleting it
	// concurrently): that delete removed nothing.
	Prev     string        `json:"prev,omitempty"`
	User     string        `json:"user,omitempty"`
	UserName string        `json:"user_name,omitempty"`
	Author   string        `json:"author,omitempty"` // offline/git fallback identity
	Device   historyDevice `json:"device"`
	Note     string        `json:"note,omitempty"`
	// Session is the agent session the op was committed during (hook-set,
	// see journal.Op.Session). It is the run card's group key and the only
	// place a session id is ever served: it is never enumerated, never a
	// column in /heat's output, and never in ?by=device — it appears here,
	// on the op that carries it, and is accepted as a ?session= filter INPUT.
	Session string `json:"session,omitempty"`
	// Seq is the op's sequence number in its device's journal: with
	// device.id it names one op uniquely, which Time (whole seconds) cannot.
	Seq int64 `json:"seq,omitempty"`
}

HistoryEntry is one change as the history API reports it.

type Identity

type Identity struct {
	ID, Name, Author string
}

Identity is the device identity uploads are journaled under.

type LocalDirectory added in v0.9.0

type LocalDirectory struct{ *OrgDB }

LocalDirectory is the built-in directory: organizations owned by this hub, stored in its own metadata store. This is what every self-hosted install runs, and its behavior is exactly OrgDB's — the type exists to add the one thing an org store has no opinion about, which is where to send a browser to administer an org.

func (LocalDirectory) ManageURL added in v0.9.0

func (LocalDirectory) ManageURL(orgID string) string

ManageURL is the hub's own org page (a route in the frontend).

type MCPAuth added in v0.16.0

type MCPAuth struct {

	// OrgName turns an org id into its display name for the consent screen. A
	// func rather than a Directory so this file stays unaware of where orgs
	// come from.
	OrgName func(string) string
	// contains filtered or unexported fields
}

MCPAuth owns grants, registered clients, and the OAuth endpoints.

func NewMCPAuth added in v0.16.0

func NewMCPAuth(repo MCPRepo, pending PendingRepo, session func(*http.Request) (User, bool), projects func(string) []MCPProject) (*MCPAuth, error)

NewMCPAuth loads the grants and clients already on disk.

func OpenMCPAuth added in v0.16.0

func OpenMCPAuth(path string, session func(*http.Request) (User, bool), projects func(string) []MCPProject) (*MCPAuth, error)

OpenMCPAuth is NewMCPAuth over the file backend, for a hub running without a configured database. Mirrors OpenShareDB.

The pending store lands beside the grants, under the same directory: a consent has to outlive the process for the same reason a grant does.

func (*MCPAuth) Grant added in v0.16.0

func (m *MCPAuth) Grant(token string) (MCPGrant, bool)

Grant resolves a bearer token to a live grant. Expiry is checked here so that every caller gets it right by construction.

func (*MCPAuth) List added in v0.16.0

func (m *MCPAuth) List(email string) []MCPGrant

List reports an account's grants, newest first, for the connections page.

func (*MCPAuth) Register added in v0.16.0

func (m *MCPAuth) Register(mux *http.ServeMux)

func (*MCPAuth) Revoke added in v0.16.0

func (m *MCPAuth) Revoke(email, id string) bool

Revoke kills a grant and both its tokens at once. A revocation that left the refresh token alive would be a revocation in name only.

func (*MCPAuth) UseCaller added in v0.16.0

func (m *MCPAuth) UseCaller(f func(*http.Request) (User, bool))

UseCaller widens list/revoke to any authenticated credential, so the CLI can manage connections. The consent screen keeps using session (cookie only).

type MCPClient added in v0.16.0

type MCPClient struct {
	ID           string    `json:"id"`
	Name         string    `json:"name,omitempty"`
	RedirectURIs []string  `json:"redirect_uris,omitempty"`
	Created      time.Time `json:"created"`
}

MCPClient is an OAuth client that registered itself (RFC 7591). There is no client secret: MCP clients are public clients and PKCE is what binds an authorization code to the requester.

type MCPConfig added in v0.16.0

type MCPConfig struct {
	Enabled bool `json:"enabled"`
}

MCPConfig turns the door on. Off by default: a hub that has not thought about agent access should not have agent access.

type MCPGrant added in v0.16.0

type MCPGrant struct {
	ID         string    `json:"id"`
	Account    string    `json:"account"` // normalized email
	ClientID   string    `json:"client_id"`
	ClientName string    `json:"client_name,omitempty"`
	Projects   []string  `json:"projects"`
	Created    time.Time `json:"created"`
	LastUsed   time.Time `json:"last_used,omitempty"`
	Expires    time.Time `json:"expires,omitempty"`
	// Digests, never plaintext — the same rule device tokens follow. A
	// metadata store that leaks gives up who connected what, not the ability
	// to act as them.
	TokenDigest   string `json:"token_digest,omitempty"`
	RefreshDigest string `json:"refresh_digest,omitempty"`
}

MCPGrant is one client's standing permission to act as one account over a chosen set of projects.

Projects is the answer to "which projects did the user tick at consent", and it is a CEILING, never a source of permission: capByGrant can only lower what projectPermFor already resolved. A grant naming a project the account later loses access to grants nothing the moment membership changes, with no revocation step and nothing to keep in sync.

type MCPProject added in v0.16.0

type MCPProject struct {
	Project
	Level string
}

MCPProject is one row of the consent screen: a project the account can connect, and the level it already holds there.

type MCPRepo added in v0.16.0

type MCPRepo interface {
	LoadGrants() ([]MCPGrant, error)
	PutGrant(g MCPGrant) error
	DeleteGrant(id string) error
	LoadClients() ([]MCPClient, error)
	PutClient(c MCPClient) error
}

MCPRepo persists the two rows the MCP door needs: OAuth clients that registered themselves (RFC 7591 dynamic registration — an MCP client arrives knowing nothing about this hub, so the hub has to hand it an identity) and the grants those clients hold.

Both in one repo because neither is useful without the other and MetaStore is an interface every backend must implement in full: two methods for one feature is two migrations for every future backend.

type Mailer

type Mailer struct {
	Host string // e.g. smtp.gmail.com
	Port int    // e.g. 587 (STARTTLS)
	User string
	Pass string
	From string // e.g. drive@example.com
}

Mailer sends plain-text mail over SMTP — the lowest-common-denominator transport a self-hoster can point at anything (Gmail app password, SES, Mailgun, a local relay). No SDK, stdlib only. A nil Mailer reports itself as unconfigured so callers can fall back to logging the message.

func (*Mailer) Send

func (m *Mailer) Send(to, subject, body string) error

type MetaStore added in v0.3.0

type MetaStore interface {
	Accounts() AccountRepo
	Projects() ProjectRepo
	Orgs() OrgRepo
	Shares() ShareRepo
	MCP() MCPRepo
	Devices() DeviceRepo
	Reads() ReadRepo
	SessionReads() SessionReadRepo
	Pending() PendingRepo
	Apps() AppRepo
	Close() error
}

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.

func OpenFileStore added in v0.3.0

func OpenFileStore(dir string) (MetaStore, error)

OpenFileStore builds the file backend over dir, using the historical filenames (auth.json, projects.json, orgs.json, shares.json, devices.json).

func OpenSQLStore added in v0.3.0

func OpenSQLStore(driver, dsn string) (MetaStore, error)

OpenSQLStore opens (and migrates) a SQL metadata store. driver is "sqlite" or "pgx" (Postgres/Supabase); dsn is the connection string / file path.

type MoveSource added in v0.15.0

type MoveSource interface {
	FilesWithMoves(context.Context) (map[string]FileInfo, moveIndex, error)
}

MoveSource is a Source that can also report where its files came from. Optional, like Uploader: implementing it keeps the replay ONE pass, so the move index costs no extra journal read.

type Node

type Node struct {
	Name string    `json:"name"`
	Path string    `json:"path,omitempty"`
	Dir  bool      `json:"dir"`
	Size int64     `json:"size,omitempty"`
	Time time.Time `json:"time,omitzero"`
	// Same three-field "who" shape as HistoryEntry (history.go), so the
	// frontend has one attribution helper for every surface.
	User     string `json:"user,omitempty"`
	UserName string `json:"user_name,omitempty"`
	Author   string `json:"author,omitempty"`
	Device   string `json:"device,omitempty"`
	// Link is the symlink target, verbatim. Set only on symlink entries; the
	// viewer shows it and never follows it.
	Link     string  `json:"link,omitempty"`
	Children []*Node `json:"children,omitempty"`
}

Node is one entry of the file tree returned by the tree endpoint.

type Org added in v0.3.0

type Org struct {
	ID      string            `json:"id"`
	Name    string            `json:"name"`
	Members map[string]string `json:"members"` // lowercase email → role
	Created time.Time         `json:"created"`
	// Joined records when each member row was first created. It exists for one
	// decision — who inherits an org whose sole owner is offboarded — and that
	// decision must not be readable off the address a member typed at signup.
	// Rows written before this field existed carry a zero time and lose to any
	// dated row, which is right: they are the oldest members there are.
	Joined map[string]time.Time `json:"joined,omitempty"`
}

Org is one organization.

type OrgDB added in v0.3.0

type OrgDB struct {
	// contains filtered or unexported fields
}

OrgDB is the in-memory org registry over a MetaStore OrgRepo (orgs + invites).

func NewOrgDB added in v0.3.0

func NewOrgDB(repo OrgRepo) (*OrgDB, error)

NewOrgDB builds the registry over a repo, loading orgs and invites.

func OpenOrgDB added in v0.3.0

func OpenOrgDB(path string) (*OrgDB, error)

OpenOrgDB loads the file-backed registry at path.

func (*OrgDB) AddMember added in v0.3.0

func (db *OrgDB) AddMember(orgID, email, role string) error

AddMember adds (or keeps) the account in the org with the given role. An existing member's role is never downgraded by an invite.

func (*OrgDB) Create added in v0.3.0

func (db *OrgDB) Create(name, ownerEmail string) (Org, error)

Create makes a new org owned by ownerEmail.

func (*OrgDB) CreateInvite added in v0.3.0

func (db *OrgDB) CreateInvite(orgID, creator string, ttl time.Duration) (OrgInvite, error)

CreateInvite mints a join link for the org.

func (*OrgDB) EvictMember added in v0.15.0

func (db *OrgDB) EvictMember(orgID, email string) error

EvictMember drops an account from the org unconditionally — the form offboard needs when the ACCOUNT itself is gone. The last-owner rule keeps a LIVE org administrable; it must never preserve an ownership row for an address nobody can sign in as, because the next signup on that address inherits it — org ownership, and through it admin on every project in the org. An org left with no owner is a recovery problem, not an authorization one. Dropping the sole owner is where this differs from RemoveMember in the other direction too: every org route is gated on RoleOwner and nothing adopts an ownerless org, so an org left with members and no owner can never again gain one, lose one, or change a role. The longest-standing remaining member is promoted instead — by Org.Joined, never by the address string. Round 8's heir was `lowestMember`, the smallest address, so the successor to every org was decided by what a member typed at signup: someone who joined last through an ordinary invite, holding no grant on anything, inherited org ownership and with it admin on every project in the org — triggered by the most routine operator action there is.

func (*OrgDB) Get added in v0.3.0

func (db *OrgDB) Get(id string) (Org, bool)

func (*OrgDB) ListInvites added in v0.3.0

func (db *OrgDB) ListInvites(orgID string) []OrgInvite

ListInvites returns the org's live (non-expired) invites.

func (*OrgDB) Memberships added in v0.18.1

func (db *OrgDB) Memberships(email string) map[string]string

Memberships is every org the account belongs to, with its role, from ONE registry read — for a caller about to ask Role for many orgs in a row.

func (*OrgDB) OrgsFor added in v0.3.0

func (db *OrgDB) OrgsFor(email string) []Org

OrgsFor returns the orgs the account belongs to, sorted by name.

func (*OrgDB) RecordInviteUse added in v0.3.0

func (db *OrgDB) RecordInviteUse(token string)

RecordInviteUse bumps the join counter for an invite (best effort).

func (*OrgDB) Redeem added in v0.3.0

func (db *OrgDB) Redeem(token string) (OrgInvite, bool)

Redeem consumes nothing — an invite link can onboard a whole team until it expires — it just resolves the token to its live invite.

func (*OrgDB) RemoveMember added in v0.3.0

func (db *OrgDB) RemoveMember(orgID, email string) error

RemoveMember drops an account from the org. The last owner cannot be removed (an org must always have someone who can administer it).

func (*OrgDB) Rename added in v0.3.0

func (db *OrgDB) Rename(orgID, name string) error

Rename changes the org's display name.

func (*OrgDB) RevokeInvite added in v0.3.0

func (db *OrgDB) RevokeInvite(token string) bool

RevokeInvite deletes an invite so its link stops working immediately.

func (*OrgDB) Role added in v0.3.0

func (db *OrgDB) Role(orgID, email string) string

Role returns the account's role in the org, or "" for non-members.

func (*OrgDB) SetRole added in v0.3.0

func (db *OrgDB) SetRole(orgID, email, role string) error

SetRole changes an account's role. Demoting the last owner is refused.

func (*OrgDB) SetSeniority added in v0.15.0

func (db *OrgDB) SetSeniority(f func() []string)

SetSeniority installs the oldest-first account order (AuthProvider.Accounts). It is set per call rather than at construction because a directory can be rebuilt from its repo at any time, and a tie-break that silently reverts to the address a member typed is round 8's escalation back.

func (*OrgDB) ValidInvite added in v0.3.0

func (db *OrgDB) ValidInvite(token string) bool

ValidInvite reports whether a token is a live invite, without consuming it. It lets the signup page permit account creation from an invite link even when public self-signup is closed (invite-only hubs).

type OrgInvite added in v0.3.0

type OrgInvite struct {
	Token   string    `json:"token"`
	Org     string    `json:"org"`
	Creator string    `json:"creator,omitempty"` // account email
	Created time.Time `json:"created"`
	Expires time.Time `json:"expires"`
	Uses    int       `json:"uses"` // how many accounts have joined via this link
}

OrgInvite is a mint-once join link. Redeeming it while signed in adds the account to the org as a member.

type OrgRepo added in v0.3.0

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 PendingGrant added in v0.16.0

type PendingGrant struct {
	Kind    string // who owns it: "cli", "oauth-state", "mcp-code"
	Key     string // unique within the kind
	Payload []byte
	Expires time.Time
}

PendingGrant is one piece of short-lived, single-use sign-in state: a

pending `bdrive login`, an OAuth state nonce, an MCP consent code.

Payload is OPAQUE to the store. Those three have nothing in common except a
key, a deadline and single use, so encoding what each holds here would put
several owners' shapes into one schema and buy nothing; each owner marshals
its own.

type PendingRepo added in v0.16.0

type PendingRepo interface {
	Put(g PendingGrant) error
	// Get reads without consuming — the device-flow poll reads the same grant
	// until a human approves it.
	Get(kind, key string) (PendingGrant, bool, error)
	// Take reads and deletes as ONE step. Single use has to be atomic, or two
	// pollers win the same code.
	Take(kind, key string) (PendingGrant, bool, error)
	Delete(kind, key string) error
	Prune(now time.Time) error
}

PendingRepo persists those grants.

This exists because every flow that uses them spans MORE THAN ONE REQUEST —
`bdrive login` is mint, approve, poll; an OAuth sign-in is redirect and
callback — and until now each hop had to land on the same PROCESS, because
the state was a map inside it. That is a coin flip per hop behind two
instances, and it is the real blocker on raising max_instance_count, not
the journal (docs/hub-load-prd.md Phase 3).

Expiry is enforced on READ, not by a sweeper: an expired grant must be
refused whether or not anything has pruned lately. Prune only reclaims
space.

func NewMemoryPending added in v0.16.1

func NewMemoryPending() PendingRepo

NewMemoryPending is the in-memory PendingRepo, for a caller that needs the

default rather than a store.

Exported because the seam is used from outside this package — the managed
hub's auth provider holds the same kind of short-lived sign-in state — and a
caller that has no MetaStore configured should be able to get the SAME
implementation the rest of the hub falls back to, not write a second one.
Two in-memory stores with slightly different expiry rules is how the two
come to disagree, which is the whole reason memPending exists.

type Project

type Project struct {
	ID          string    `json:"id"`
	Name        string    `json:"name"`
	Org         string    `json:"org,omitempty"` // owning organization
	Created     time.Time `json:"created"`
	Description string    `json:"description,omitempty"` // optional one-line subtitle
	Icon        string    `json:"icon,omitempty"`        // optional lucide icon name
	// Creator is the account that first created the project; it gets an
	// explicit admin grant at creation. Empty on projects that predate
	// per-project permissions — those are governed by org owners.
	Creator string `json:"creator,omitempty"`
	// Template is the starting structure the project was created from
	// (internal/templates), empty for an empty project. Set once, at
	// creation, by whoever seeded it — it is what stops a second surface
	// seeding a second copy.
	Template string `json:"template,omitempty"`
	// Default is the level every org member gets without an explicit grant.
	// Empty means write: the historical behavior, so no row needs migrating.
	Default string `json:"default,omitempty"`
	// Perms are the explicit grants, lowercase email → level.
	Perms map[string]string `json:"perms,omitempty"`
	// GrantedBy names, for each app grant in Perms, the member who gave it.
	// The grant counts only while that member could still give it
	// (Server.honoredGrants). Absent for an account's grant, and for an app
	// grant made before grants were attributed.
	GrantedBy map[string]string `json:"granted_by,omitempty"`
	// AppFolders are apps' grants on single folders. They sit beside Folders,
	// never in them: a folder rule decides what everyone gets under its
	// prefix, and a grant made for one app must not change that for anyone
	// else. Only that app's own view reads them (Server.honoredGrants).
	AppFolders []AppFolderGrant `json:"app_folders,omitempty"`
	// Folders narrow those levels over individual subtrees (folders.go).
	// Absent on every project that has never restricted one, which is why no
	// row needs migrating and a hub without rules behaves exactly as before.
	Folders []FolderRule `json:"folders,omitempty"`
	// Data is whether the hub indexes this project's JSON and Markdown
	// frontmatter for data queries (dataindex.go). A project admin turns it
	// on; it is hub metadata, like Folders, never a file in the project.
	Data bool `json:"data,omitempty"`
}

Project is one synced project hosted by this server. Its storage lives under <root>/<id>/ in the object store; the id is permanent, the name is a renameable label.

type ProjectDB

type ProjectDB struct {
	// contains filtered or unexported fields
}

ProjectDB is the server's project registry: an in-memory index over a MetaStore ProjectRepo. Reads are served from memory; every change is persisted as one record through the repo (file or SQL).

func NewProjectDB added in v0.3.0

func NewProjectDB(repo ProjectRepo) (*ProjectDB, error)

NewProjectDB builds the registry over a repo, loading its current contents.

func OpenProjectDB

func OpenProjectDB(path string) (*ProjectDB, error)

OpenProjectDB loads the file-backed registry at path (a missing file is an empty registry) — the zero-dependency default.

func (*ProjectDB) ClearFolder added in v0.16.0

func (db *ProjectDB) ClearFolder(id, prefix string) error

ClearFolder removes one rule, reverting its subtree to the project level.

func (*ProjectDB) ClearPerm added in v0.11.0

func (db *ProjectDB) ClearPerm(id, email string) error

ClearPerm drops an explicit grant, reverting the account to the default.

func (*ProjectDB) Delete added in v0.3.0

func (db *ProjectDB) Delete(id string) error

Delete removes a project from the registry. Its storage prefix (blobs, journals) is left in the object store — the id is retired, not scrubbed — so the caller decides whether to reclaim that space out of band.

func (*ProjectDB) DropAppFolder added in v0.18.1

func (db *ProjectDB) DropAppFolder(id, prefix, app string) error

DropAppFolder removes an app's grant on one folder.

func (*ProjectDB) Get

func (db *ProjectDB) Get(id string) (Project, bool)

func (*ProjectDB) GetOrCreate

func (db *ProjectDB) GetOrCreate(name, org string) (Project, bool, error)

GetOrCreate returns the project with the given name in the org, creating it (with a fresh id) if none exists. Names are matched exactly, scoped to the org: two organizations can each have a "wiki".

func (*ProjectDB) List

func (db *ProjectDB) List() []Project

func (*ProjectDB) Rename added in v0.3.0

func (db *ProjectDB) Rename(id, name string) error

Rename changes a project's display name (its id and storage are permanent).

func (*ProjectDB) SetAppFolder added in v0.18.1

func (db *ProjectDB) SetAppFolder(id string, g AppFolderGrant) error

SetAppFolder gives an app a level on one folder, replacing its grant there. The folder's rules are not touched, so nobody else's access changes.

func (*ProjectDB) SetCreator added in v0.11.0

func (db *ProjectDB) SetCreator(id, email string) error

SetCreator records who created a project (and is its first admin).

func (*ProjectDB) SetData added in v0.18.1

func (db *ProjectDB) SetData(id string, on bool) error

SetData turns the project's data index on or off. It is written alone, for the reason folder rules are: a rename by a hub process holding an older copy of the project must not put back a setting an admin changed.

func (*ProjectDB) SetDefault added in v0.11.0

func (db *ProjectDB) SetDefault(id, level string) error

SetDefault sets the level org members get without an explicit grant.

func (*ProjectDB) SetFolder added in v0.16.0

func (db *ProjectDB) SetFolder(id string, rule FolderRule) error

SetFolder upserts one folder rule (folders.go). The prefix must already be normalized by normPrefix — this is the registry, not the door.

There is no last-admin guard here and there does not need to be: a folder rule cannot carry PermAdmin (handleProjectFolderSet refuses it) and pathPermOf returns admin unchanged for anyone who holds it, so no rule can leave a project unadministrable.

func (*ProjectDB) SetOrg added in v0.3.0

func (db *ProjectDB) SetOrg(id, org string) error

SetOrg moves a project into an org (used by the startup migration).

func (*ProjectDB) SetPerm added in v0.11.0

func (db *ProjectDB) SetPerm(id, email, level string) error

SetPerm grants one account an explicit level on the project. Demoting the last explicit admin is refused, the same shape as OrgDB's last-owner rule: a project must keep someone who can administer it (org owners aside, who are implicitly admin and never appear in this list).

func (*ProjectDB) SetPermBy added in v0.18.1

func (db *ProjectDB) SetPermBy(id, email, level, by string) error

SetPermBy is SetPerm recording who gave the grant: an app's grant counts only while they could still give it.

func (*ProjectDB) SetTemplate added in v0.14.0

func (db *ProjectDB) SetTemplate(id, name string) error

SetTemplate records the starting structure a project was seeded from.

func (*ProjectDB) Update added in v0.11.0

func (db *ProjectDB) Update(id string, name, description, icon *string) error

Update changes a project's editable metadata. Each field is a pointer so that "absent" (nil, leave alone) is distinguishable from "present and empty" (clear it) — the whole point of a partial update. One lock, one repo write, whatever the caller changed.

type ProjectRepo added in v0.3.0

type ProjectRepo interface {
	Load() ([]Project, error)
	Put(p Project) error
	Delete(id string) error
}

type QuotaProvider added in v0.3.0

type QuotaProvider interface {
	// CheckWrite runs before addedBytes land in the org's storage; a non-nil
	// error rejects the write (surfaced to the client as 403).
	CheckWrite(org string, addedBytes int64) error
	// CheckSeat runs before an invite adds a member; members is the current
	// count. A non-nil error rejects the join.
	CheckSeat(org string, members int) error
	// RecordUsage runs after a write succeeds, for accounting.
	RecordUsage(org string, addedBytes int64)

	// CheckRead runs before bytes are served to an UNAUTHENTICATED reader —
	// today that is public share links (/s/*) and nothing else. bytes is the
	// size about to be streamed. A non-nil error refuses the transfer and its
	// message is shown to the reader, so write it for a stranger who has no
	// idea what BearDrive is.
	//
	// Deliberately NOT called on the sync proxy or the viewer: a device that
	// gets refused mid-sync reads it as "access revoked" and stops touching
	// the folder, which is a far worse outcome than an over-quota bill. Those
	// paths are governed by fair use.
	CheckRead(org string, bytes int64) error
	// RecordEgress is CheckRead's other half: it runs after a share-link
	// transfer with the number of bytes actually written, and reports
	// nothing else, so a provider can enforce CheckRead against what it has
	// recorded.
	RecordEgress(org string, bytes int64)
}

QuotaProvider is the seam a managed deployment uses to enforce plan limits, exactly like AuthProvider is the seam for identity. The open-source server ships only UnlimitedQuota; billing and plan logic live outside this repo. Hooks fire on every write path (browser uploads, the device sync store proxy) and on seat growth, keyed by org id.

type ReadLedger added in v0.4.0

type ReadLedger struct {
	// contains filtered or unexported fields
}

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.

func NewReadLedger added in v0.4.0

func NewReadLedger(repo ReadRepo, retentionDays int) (*ReadLedger, error)

NewReadLedger loads the ledger and immediately folds buckets older than the retention horizon into their all-time rows. retentionDays <= 0 means the default.

func OpenReadLedger added in v0.4.0

func OpenReadLedger(path string, retentionDays int) (*ReadLedger, error)

OpenReadLedger loads the file-backed ledger at path.

func (*ReadLedger) AgentHeat added in v0.4.0

func (l *ReadLedger) AgentHeat(project string, since time.Time) map[string]map[string]int64

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 (*ReadLedger) Close added in v0.4.0

func (l *ReadLedger) Close() error

Close flushes any pending buckets.

func (*ReadLedger) Heat added in v0.4.0

func (l *ReadLedger) Heat(project, prefix string, since time.Time) map[string]HeatEntry

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 (*ReadLedger) Record added in v0.4.0

func (l *ReadLedger) Record(project, path, kind, actor string)

Record counts one read. Nil-safe and never fails: telemetry must not break the page view (or sync cycle) that triggered it.

func (*ReadLedger) RecordSession added in v0.15.0

func (l *ReadLedger) RecordSession(project, session, device, path string)

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 (*ReadLedger) SessionPaths added in v0.15.0

func (l *ReadLedger) SessionPaths(project, session, device string) []string

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 (*ReadLedger) ShareOpens added in v0.15.0

func (l *ReadLedger) ShareOpens(project string) map[string]ShareOpen

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 scan of the project's buckets, never one per share: callers build this map once and index it, because a project with 40 links would otherwise pay 40 scans per list render.

func (*ReadLedger) WithSessions added in v0.15.0

func (l *ReadLedger) WithSessions(repo SessionReadRepo, retentionDays int) *ReadLedger

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.

type ReadRepo added in v0.4.0

type ReadRepo interface {
	Load() ([]ReadStat, error)
	PutBatch(stats []ReadStat) error // upsert by (project, path, day, kind, actor)
	DeleteBatch(keys []ReadStatKey) 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 ReadStat added in v0.4.0

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"`
}

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 ReadStatKey added in v0.4.0

type ReadStatKey struct {
	Project, Path, Day, Kind, Actor string
}

ReadStatKey identifies one bucket.

type RemoteSource

type RemoteSource struct {
	Backend remote.Backend
	// Device identifies this server in ops it journals for uploads. Required
	// for uploads; irrelevant for reading.
	Device Identity
	// PresignTTL is the lifetime the hub gives a presigned upload URL. It is
	// how long a blob stays writable by anyone but the hub, and therefore when
	// verify may stop re-hashing it. Zero means DefaultUploadTTL — set it to
	// the server's real UploadConfig.ttl(), or a longer configured TTL would
	// seal an object that can still change.
	PresignTTL time.Duration
	// contains filtered or unexported fields
}

RemoteSource reads a beardrive remote: it fetches every journal and folds the ops into the current volume state (same total order as journal.Replay, but keeping author/device/time of the winning op per path). With Device set it also accepts uploads, journaled under that identity.

func (*RemoteSource) BlobSize added in v0.15.0

func (r *RemoteSource) BlobSize(ctx context.Context, blob string) (int64, bool, error)

func (*RemoteSource) Commit

func (r *RemoteSource) Commit(ctx context.Context, p, blob string, size int64, who User, note string) error

Commit appends a put op for path→blob to this server's own journal. It refuses if the blob is not in the store yet (a peer must never see an op whose content is missing). Only this server writes this journal key, so the read-modify-write below has a single writer; upmu serializes it across concurrent requests.

func (*RemoteSource) Files

func (r *RemoteSource) Files(ctx context.Context) (map[string]FileInfo, error)

func (*RemoteSource) FilesWithMoves added in v0.15.0

func (r *RemoteSource) FilesWithMoves(ctx context.Context) (map[string]FileInfo, moveIndex, error)

FilesWithMoves is the replay, plus the rename index derived from the same ops — one pass, so the index rides in the cached snapshot.

When the journal listing is exactly what the last fold saw, the last fold is the answer: journals only grow, so an unchanged (key, size, modified) set is an unchanged input, and replay is deterministic. That turns the periodic refresh of an idle project into one List. The maps are shared with every snapshot built from them, which is safe because nothing writes to a snapshot after it is built.

func (*RemoteSource) Open

func (r *RemoteSource) Open(ctx context.Context, _ string, fi FileInfo) (io.ReadCloser, error)

func (*RemoteSource) OpenBlob added in v0.15.0

func (r *RemoteSource) OpenBlob(ctx context.Context, sha string) (io.ReadCloser, error)

OpenBlob is the one way a blob's bytes leave the hub, and on a hub whose storage can presign it is also the only place left that can tell a content address the truth. handleStorePut hashes what it relays — but a presigned PUT writes straight into the object store, so those bytes were never examined by anything: any device with write permission could store arbitrary content under a sha256 it chose, and the viewer, share links, history and every peer would then serve it.

Skipped entirely on a backend that cannot presign, where the write path already checked. It used to be once per blob per process, on the premise that blobs are immutable — which is false on the hub that needs the check: SignPut hands out a URL that stays valid for its whole TTL and an object store accepts every PUT to it, not the first. So uploading the honest bytes, letting one reader populate the cache, and then replaying the same URL with hostile bytes served them under the reviewed sha to the viewer, history, share links and every syncing device.

Verifying on EVERY read closed that, and cost every S3/GCS hub 2x object- store egress and a serialized full-object hash before the reader's first byte — on every viewer open, render, download and /s/* hit. The cache is back, keyed on the one thing that makes the premise TRUE rather than assumed: see verify.

func (*RemoteSource) Remove added in v0.14.0

func (r *RemoteSource) Remove(ctx context.Context, p string, who User, note string) error

Remove appends a delete op for p to this server's own journal. A delete references no content, so there is no blob to push first.

func (*RemoteSource) SignBlobPut

func (r *RemoteSource) SignBlobPut(ctx context.Context, blob string, size int64, ttl time.Duration) (*remote.SignedPut, error)

SignBlobPut presigns a direct upload of the blob, if the backend can sign.

func (*RemoteSource) Upload

func (r *RemoteSource) Upload(ctx context.Context, p string, src io.Reader, _ int64, who User, note string) error

Upload stores content through the server: spool to disk while hashing, push the blob, then journal the op.

type Server

type Server struct {
	// Single-volume mode: serve exactly this source.
	Source Source
	Volume string // display only

	// Hub mode (when Root is set): many projects on one storage root.
	Root     remote.Backend
	Projects *ProjectDB

	// Device identifies this server in ops it journals for browser uploads.
	Device  Identity
	Refresh time.Duration
	Upload  UploadConfig
	// Auth, when set, gates the whole API behind sign-in. Nil means the
	// historical trusted-network behavior: no accounts, everyone welcome.
	Auth AuthProvider
	// Devices, when set, records what the server observes about syncing
	// devices (name, OS, public IP, last activity) for history.
	Devices *DeviceRegistry
	// Shares, when set, enables public share links (/s/<token>).
	Shares *ShareDB
	// MCP, when set, serves the agent door at /mcp plus its OAuth endpoints.
	// Nil means the hub has no MCP surface at all — not an unauthenticated
	// one. See MCPConfig.
	MCP *MCPAuth

	// Reads, when set, aggregates read telemetry (viewer, share, and agent
	// reads) for the heat API. Nil means read tracking is off.
	Reads *ReadLedger
	// Data, when set, indexes the JSON and Markdown frontmatter of projects
	// whose Data setting is on, for /data/query (dataindex.go). Nil means the
	// hub answers data queries with 404.
	Data *DataIndex
	// Apps, when set, enables app principals and their bda_ keys (apps.go).
	Apps *AppDB

	// Dir, when set, walls projects off by organization membership and owns
	// every org read and write the hub performs. LocalDirectory is the
	// built-in implementation; a managed deployment supplies its own so that
	// orgs come from the same place identities do. Nil means single-volume
	// mode: no orgs, every authenticated request passes.
	Dir Directory
	// Quota, when set, enforces plan limits (managed deployments). Nil
	// means UnlimitedQuota: the open-source server never says no.
	Quota QuotaProvider
	// Billing, when set, surfaces a billing entry in the frontend's account
	// menu: the billing page URL plus the signed-in user's current plan name
	// (/api/config `billing`). The OSS hub has no billing; managed
	// deployments plug this in. Nil — or ok=false for a user with no org —
	// hides the entry. The mirror of the Quota seam: Quota enforces the
	// plan, Billing displays it.
	Billing func(email string) (plan, url string, ok bool)
	// Analytics, when its Key is set, tells the frontend to load PostHog
	// (/api/config `analytics`). The third managed-deployment seam beside
	// Quota and Billing, and deliberately server-supplied rather than
	// bundled: with no key the OSS frontend ships no analytics code and
	// makes no third-party request, so a self-hosted hub cannot phone home
	// even by accident.
	Analytics AnalyticsConfig
	// ShareRPM is the per-IP request rate on public share links (/s/*);
	// 0 means DefaultShareRPM.
	ShareRPM int
	// Desktop marks the loopback sidecar posture (`bdrive desktop`): this
	// server fronts the machine's own local volume stores, not a hub. Every
	// project resolves to PermRead for every caller — the desktop viewer is
	// read-only and the existing perm machinery is the one gate — and
	// /api/config carries desktop:true plus reads.enabled (heat is proxied to
	// the hub by the desktop command, not served by a local ledger).
	Desktop bool
	// ReportRead, when set, is handed every viewer read this server serves.
	// It exists for the desktop sidecar: that server answers the viewer routes
	// from local state and keeps no ledger, so a person reading a file in the
	// Mac app reached no ledger at all while the same file opened in the web
	// app counted. The hook lets the sidecar forward the read to the project's
	// hub as human traffic. Nil on a hub, where Reads records it directly.
	// Must not block: it is called on the request path.
	ReportRead func(project, path string)
	// DesktopMe, when set alongside Desktop, supplies the signed-in account
	// for /api/config `me` — the desktop has no Auth provider, its session
	// is the device's saved sign-in (settings.json), which the tray can
	// change at runtime, hence a func rather than a field.
	DesktopMe func() (email, name string)

	// TrustProxy honors X-Forwarded-For from ANY peer. Only needed for a
	// proxy on a public address: a proxy on loopback or a private network is
	// already trusted without it (see clientIP). Setting it on a directly-
	// reachable hub lets any client pick its own rate-limit bucket.
	TrustProxy bool
	// contains filtered or unexported fields
}

Server renders volumes as a website and, in hub mode, brokers sync for client devices.

func (*Server) ConnectableProjects added in v0.16.0

func (s *Server) ConnectableProjects(email string) []MCPProject

ConnectableProjects is what an account may offer to an MCP client: every project it can already read, each carrying the level it holds there.

Read, not write, is the bar: connecting a project you can only view is a legitimate thing to want, and the grant inherits the same read ceiling.

func (*Server) Handler

func (s *Server) Handler() http.Handler

Handler returns the HTTP handler: /api/* plus the embedded frontend.

func (*Server) MCPHandler added in v0.16.0

func (s *Server) MCPHandler() http.Handler

MCPHandler is the /mcp endpoint: OAuth check, then the streamable HTTP transport. Stateless + JSONResponse is the simplest posture that is also correct behind more than one hub process — there is no session affinity to get wrong because there is no session.

func (*Server) OrgName added in v0.16.0

func (s *Server) OrgName(id string) string

OrgName is an org's display name, for the consent screen's grouping.

func (*Server) RequestUser added in v0.16.0

func (s *Server) RequestUser(r *http.Request) User

RequestUser is requestUser for callers outside this package (cmd/bdrive wires it into MCPAuth so the CLI can manage connections).

func (*Server) SessionUser added in v0.16.0

func (s *Server) SessionUser(r *http.Request) (User, bool)

SessionUser resolves a BROWSER session — cookie only. The consent screen uses it to decide who is granting access, and it must never accept a Bearer token: an MCP access token that could reach the consent screen could widen its own project set without a human in the loop.

func (*Server) UseRelay added in v0.16.0

func (s *Server) UseRelay(r eventRelay)

UseRelay attaches a cross-process relay to this hub's change stream. Safe to call before or after the fan-out has been built; a nil relay leaves the hub exactly as it is, which is what a single-instance deployment wants.

type SessionRead added in v0.15.0

type SessionRead struct {
	Project string    `json:"project"`
	Session string    `json:"session"`
	Device  string    `json:"device"`
	Path    string    `json:"path"`
	Last    time.Time `json:"last"`
}

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 SessionReadRepo added in v0.15.0

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
}

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.

func OpenSessionReadRepo added in v0.15.0

func OpenSessionReadRepo(path string) SessionReadRepo

OpenSessionReadRepo is the file-backed session-read store, for hubs running without a MetaStore (the historical JSON-files layout).

type Share

type Share struct {
	Token   string    `json:"token"`
	Project string    `json:"project"`
	Path    string    `json:"path"`
	Creator string    `json:"creator,omitempty"` // account email
	Created time.Time `json:"created"`
	Expires time.Time `json:"expires,omitzero"` // zero = permanent until revoked
}

Share is one public link.

type ShareDB

type ShareDB struct {
	// contains filtered or unexported fields
}

ShareDB is the in-memory share registry over a MetaStore ShareRepo.

func NewShareDB added in v0.3.0

func NewShareDB(repo ShareRepo) (*ShareDB, error)

NewShareDB builds the registry over a repo, loading its contents.

func OpenShareDB

func OpenShareDB(path string) (*ShareDB, error)

OpenShareDB loads the file-backed registry at path.

func (*ShareDB) Create

func (db *ShareDB) Create(project, p, creator string, ttl time.Duration) (Share, error)

Create returns a share for (project, path), reusing an existing live one so repeated shares of the same file hand out the same URL.

func (*ShareDB) Get

func (db *ShareDB) Get(token string) (Share, bool)

Get resolves a live (non-expired) share.

func (*ShareDB) List

func (db *ShareDB) List(project string) []Share

List returns a project's live shares, newest first. The order has to be a total one: byToken is a map, so without a sort every call reshuffles the rows — and these rows carry a Revoke button, so "the second one" must mean the same link on every load.

func (*ShareDB) Revoke

func (db *ShareDB) Revoke(token string) bool

func (*ShareDB) SetExpiry added in v0.12.0

func (db *ShareDB) SetExpiry(token string, ttl time.Duration) (Share, bool, error)

SetExpiry re-dates a live share in place: ttl > 0 sets the expiry, ttl == 0 makes it permanent again. The token is untouched, so a URL already on someone's clipboard keeps working — which is the point, and why this is not revoke-and-remint.

type ShareOpen added in v0.15.0

type ShareOpen struct {
	Count int64
	Last  time.Time
}

ShareOpen is share-link consumption for one path: visits, and when.

type ShareRepo added in v0.3.0

type ShareRepo interface {
	Load() ([]Share, error)
	Put(s Share) error
	Delete(token string) error
}

type SignupPolicy added in v0.9.0

type SignupPolicy struct {
	RequireVerification bool     `json:"require_verification"`
	RequireApproval     bool     `json:"require_approval"`
	AllowSignup         bool     `json:"allow_signup"`
	AllowedDomains      []string `json:"allowed_domains"` // read-only
	Admins              []string `json:"admins"`          // read-only
	Mailer              bool     `json:"mailer"`          // SMTP configured?
}

SignupPolicy is what /api/admin/policy reports: which gates are on, and which of them are server-config owned (read-only to a browser session, so that no one can widen access by clicking).

type Source

type Source interface {
	Files(ctx context.Context) (map[string]FileInfo, error)
	Open(ctx context.Context, path string, fi FileInfo) (io.ReadCloser, error)
}

Source supplies the file set and content of one volume. Implementations: RemoteSource (a beardrive remote) and DirSource (a plain local folder).

type SyncEgressRecorder added in v0.18.1

type SyncEgressRecorder interface {
	RecordSyncEgress(org string, bytes int64)
}

SyncEgressRecorder is the sync proxy's meter, an optional QuotaProvider capability: RecordSyncEgress runs after /store/* served a device, with the bytes that left the socket (compressed, when the response was).

It is not RecordEgress because (org, bytes) cannot say which door the bytes left by: when sync reported to RecordEgress, a team's own syncing spent the public share allowance until every link in the org was refused. Measured, never enforced — there is no check for it, and a provider must not feed it into one.

Optional rather than a QuotaProvider method because the managed hub pins this repo and a new required method fails its build on the bump; a provider without it is simply not told, and nothing is refused.

type UnlimitedQuota added in v0.3.0

type UnlimitedQuota struct{}

UnlimitedQuota is the open-source default: everything is allowed.

func (UnlimitedQuota) CheckRead added in v0.15.0

func (UnlimitedQuota) CheckRead(string, int64) error

func (UnlimitedQuota) CheckSeat added in v0.3.0

func (UnlimitedQuota) CheckSeat(string, int) error

func (UnlimitedQuota) CheckWrite added in v0.3.0

func (UnlimitedQuota) CheckWrite(string, int64) error

func (UnlimitedQuota) RecordEgress added in v0.15.0

func (UnlimitedQuota) RecordEgress(string, int64)

func (UnlimitedQuota) RecordUsage added in v0.3.0

func (UnlimitedQuota) RecordUsage(string, int64)

type UploadConfig

type UploadConfig struct {
	Enabled bool
	// TTL bounds the lifetime of presigned direct-upload URLs.
	TTL time.Duration
}

UploadConfig controls whether and how clients may write.

type Uploader

type Uploader interface {
	Upload(ctx context.Context, path string, r io.Reader, size int64, who User, note string) error
}

Uploader is implemented by sources that accept writes through the server. who is the signed-in account the write should be attributed to (zero when auth is off), and note rides along on the journaled op — "" for an ordinary browser upload, a stated origin when the HUB itself authored the bytes (seedTemplate). Both are on the interface because the journal is the hub's only audit surface: a write with no human behind it must not be able to present as one by omission.

type User

type User struct {
	ID    string `json:"id"`
	Email string `json:"email"`
	Name  string `json:"name"`
	Admin bool   `json:"admin,omitempty"` // hub admin (approve users, govern shares)
}

User is an authenticated account as the rest of the server sees it.

type Versioned added in v0.15.0

type Versioned interface {
	Version() (string, error)
}

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL