The hub already abstracted authentication — AuthProvider, with BuiltinAuth
as the built-in implementation — and then reached around that seam three
times: Accounts() was declared on neither interface, admin.go type-asserted
*BuiltinAuth (five handlers silently degraded to 404/empty under any other
provider), and organizations were not on the seam at all.
That last gap had teeth. A deployment whose identities come from elsewhere
had no way to own its orgs, so the code that did own them wrote into the
hub's OrgDB from the side — and nothing stopped the hub from inventing an
org that the identity system had never heard of. One did: a hub-created org
held every project while the mirrored one sat empty, and no sync path could
see the difference.
Directory (directory.go) is where organizations live now. LocalDirectory
wraps today's OrgDB unchanged — same last-owner protection, same normEmail,
same "o-"+randHex(4) ids, same file/SQL persistence — so a self-hosted hub
behaves exactly as before. A deployment whose orgs are owned elsewhere
implements the same interface, returns ErrManagedElsewhere from the write
half, and the handlers answer 409 with ManageURL. The hub never learns why
a write was refused, only where to send the user.
Two rules shape the interface. Reads are on the request path: Role runs on
every project request, including the /store/* endpoints a device hits every
few seconds with a token that carries no identity claims, so an
implementation backed by a remote system answers from its own cache — and
that cache is its business, not the hub's. Writes are optional, because
"this hub owns its orgs" is a deployment fact, not a code path.
- Server.Orgs *OrgDB becomes Server.Dir Directory: 28 call sites, 8
nil-checks, one writeDirErr helper for the 409 translation.
- /api/orgs gains manage_url per org — the destination of the account
menu's Settings entry. The client follows a link and never branches on
which kind of hub it is talking to.
- Org administration becomes a real route, /orgs/<id>, retiring one of the
two URL-less panels CLAUDE.md grandfathers. When a directory's ManageURL
is not hub-local, the SPA fallback redirects there instead — so a hub that
cannot administer its orgs cannot paint a console whose every control 409s.
- Accounts() moves onto AuthProvider. admin.go's type assertion becomes an
optional AccountApprover, and a provider without one now answers 503
rather than an empty approval queue: "no queue here" and "queue is empty"
are different answers and only one of them was true.
Two reviews drove the rest. The architecture review caught a browser page
load that could delete org members (a display read ran the full membership
reconcile, and a 200 with an empty user list evicted everyone), one write
site that escaped the 409 translation, and a webhook that could wedge an
event stream behind an unappliable event. The design review, over eight
rounds, caught the org page rendering live controls on a hub that cannot
use them, a share link made unrevokable by a long filename, nine keyboard
tab stops parked off-screen behind a closed drawer, and — five separate
times — a fix of mine that looked right in the source and did nothing in
the browser.
Conformance tests run both a writable and a read-only implementation against
one contract; the seat, prune, and out-of-order regressions each have a test
written to fail against the old code.
Phase 3 of the read heatmap: `bdrive read-log` parses any platform's hook
event JSON from stdin and queues in-project file reads (mount-relative,
ignore/include-filtered) in a per-volume spool — no network on the hook
path. The sync cycle drains the spool best-effort to the hub's new
POST /api/p/<id>/reads via the remote ReadReporter capability (https
backend only); a failed report retries next cycle and never fails or
offlines the cycle. `bdrive hooks install` now registers a third hook per
platform on its read-tool matcher (claude Read, codex read_file
best-effort, gemini read_file|read_many_files, hermes read_file), each
idempotent on its own marker so sync-only configs upgrade in place. Agent
reads land as agent traffic in the heat view, actor = device id.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
`bdrive sync --note <text>` stamps session context onto every op the cycle
commits and persists it in the volume store (note.json, --note-ttl default
30m) so the daemon's own scans stamp it too — winning the race between
one-shot hook syncs and the 3s daemon scan. The plugin sync hook extracts
session_id from hook stdin JSON and passes it automatically, so history
links every change to the Claude Code session that made it. Conflict-copy
ops keep their own note; expired/cleared notes stop applying.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
A subdirectory with its own .bdrive/config.json now syncs only through
its own project: the parent mount never scans into it, materializes
over it, or emits deletes for paths under it (cached paths are dropped
without delete ops, the same posture as newly ignored files). Detection
is presence-based (config.IsMount) so even a corrupt nested config
still fences. This makes the team-knowledge-folder-inside-a-personal-
mount topology safe.
The plugin's init command and skill gain a "connecting knowledge
tooling" playbook: a detection ladder (gbrain -> OKF wiki -> plain
docs folder -> starting-point menu) and two hard rules — one transport
per folder (git-tracked dirs are handed off via git rm --cached +
gitignore before syncing) and knowledge-as-scoped-subfolder (never a
repo root). Conflict copies documented with their exact glob
(*.bdrive-conflict-*), since openknowledge validate cannot see them.
New flow tests: shared-subfolder scope both ways, teammate connect
over identical/divergent local content, nested mount syncing
independently on two projects, and a guard pinning the conflict-copy
filename to the documented glob.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
The initial import was latency-bound: the push loop uploaded blobs strictly
serially, doing an Exists round-trip then a Put per blob (~2-3N sequential
round-trips against the hub).
- Upload unique blobs in parallel (errgroup, 16 in flight) — the main win.
- Drop the separate Exists round-trip: the backend's Put is idempotent and
the hub already skips content it has (reported during signing), so the
check was redundant.
- Session.OnProgress emits upload progress (done/total files + bytes) from
the push phase; the CLI renders an in-place bar on a TTY and periodic
percentage lines otherwise, wired into `bdrive init`'s initial cycle and
`bdrive sync`. The daemon stays silent.
Multi-device convergence tests pass with -race; new TestPushProgress covers
the progress emission.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
Every hub project now belongs to exactly one org (file-backed orgs.json,
same load-at-open + atomic-rewrite discipline as the other registries).
Membership (email -> owner|member) gates every per-project route: the
viewer APIs, uploads, history, blobs, shares management, and the device
sync store proxy; /api/projects lists only your orgs' projects, and
project names are now scoped per org. Public share links (/s/) stay
public by design.
Design choices, per the simplest-consistent rule:
- Migration: a pre-org hub sweeps all org-less projects into a "default"
org at startup; ALL existing accounts join it (they could all see every
project before, so anything narrower would lock someone out), oldest
account as owner. Zero manual steps.
- An account in no org that creates a project gets a fresh org named
after itself, so nobody is ever blocked from starting to sync.
- Invites are expiring multi-use links (default 7 days): an owner mints
/#join/<token>, any signed-in account that opens it joins as member.
The web UI shows the org in a sidebar footer (members on click,
Invite button for owners).
bdrive init needed no changes: its connect-existing flow lists projects
through the now-filtered API.
Tests: OrgDB + migration units; a 403/access matrix over every
per-project route; invite mint/redeem flow; and a multi-device syncer
test proving a device holding an org-B token can neither pull org A's
files nor push into its store (sync degrades to Offline, never partial).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
Authentication (previous phase, now landed together with its follow-ups):
- Email+password+name accounts behind an AuthProvider interface; the OSS
server ships BuiltinAuth only (file-backed auth.json: bcrypt password
hashes + SHA-256 token digests, plaintext never stored; server-owned
/auth/* pages; managed deployments can swap in another provider).
- bdrive login: loopback-callback browser flow (sign-up on the page, the
terminal finishes itself) with a device-code fallback for headless
machines; long-lived revocable device tokens in settings.json.
- Password reset via plain SMTP (stdlib) with a log-link fallback when no
SMTP is configured.
Move-proof projects:
- .bdrive is now a directory; config.json carries a stable mount id.
The volume store (~/.bdrive/volumes/<mount-id>/) and registry are keyed
by that id — never the folder path — so renames/moves are free.
- The daemon re-reads the project config each tick and exits cleanly
(propagating nothing) when its folder vanishes; the registry self-heals
and the next bdrive command at the new location resumes with zero
spurious changes.
bdrive init is the front door (mnt/umnt removed; bdrive stop pauses):
- Interactive on a TTY (create new / connect existing project from the
server's list; whole folder / shared subfolder via the include list),
full flag bypass (--name/--project/--shared/--yes), never prompts
without a TTY. Runs the login flow first when there is no session.
Default server: beardrive.ai (config.DefaultServer).
Web history (revert-ready):
- Hubs now always require auth; journal ops carry the signed-in account
(user/user_name) alongside the git/OS fallback author.
- File-backed device registry: per-device name, OS, account, and the
public IP the server observed, joined into history at read time.
- GET /api/p/<id>/history?path=|prefix= (newest first) and
GET /api/p/<id>/blob?sha= stream any exact version — blobs are retained
forever, so the next phase's revert is re-putting an old blob.
- UI: History button (file versions or project feed), per-folder history
shortcut, view/download of any past version.
Tests: auth flows (callback, device-code, reset single-use, persistence,
gating), history API + device registry, folder-move survival, registry
self-heal, ops-carry-account; docs (README/SKILL/CLAUDE) updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
The web server (bdrive web) becomes a full sync hub, and client devices
get one-command onboarding — without ever seeing storage info or holding
cloud credentials:
- bdrive web -c config.json: server configurable from a JSON file
(remote/addr/upload/upload_ttl/projects_db); explicit flags win.
- Hub mode: pointing bdrive web at a storage root hosts many projects,
each under <root>/<project-id>/ (remote.Prefixed). Projects live in a
file-backed registry (projects.json — loaded at open, rewritten
atomically per change) with create-or-join-by-name semantics.
- Per-project APIs: /api/projects (list/create/get) and
/api/p/<id>/{tree,file,render,download,upload/*,store/*}. The web UI
grows a project list with per-project browsing and hash deep links.
- Browser uploads and a store proxy for syncing devices: presigned
direct-to-storage PUTs when the backend can sign (S3 presign, GCS V4
signed URLs; expiring, credential-free), relayed through the server
otherwise. Journals are never presigned — only immutable blobs.
Blobs-before-journal and one-writer-per-journal invariants hold.
- https:// remote backend: a device syncs one hub project through
/api/p/<id>/store/* — mnt/sync/daemon/log all work unchanged.
- bdrive login <url>: verify a hub and remember it as the device default
(settings.json). bdrive init: create-or-join a project named after the
folder (--name/--project override), write .bdrive, seed a starter
.bdriveignore, mount, and start the daemon — one command per project.
- Hard-break rename: .beardrive->.bdrive, .beardriveignore->.bdriveignore,
~/.beardrive->~/.bdrive, BEARDRIVE_HOME->BDRIVE_HOME, temp/conflict
prefixes; old names are no longer read.
- Tests: presigning, project registry persistence, store API validation
and gating, project isolation over live HTTP, browser upload flows, and
two-device convergence through a hub (incl. read-only pull-only mode).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
Product and project are BearDrive; the CLI binary is bdrive (bdrive mnt,
bdrive sync, ...), the web viewer is bdrive-web. All conventions follow
the full name: .beardrive settings file, .beardriveignore, ~/.beardrive
home, BEARDRIVE_HOME, .beardrive-conflict-* / .beardrive-tmp-* files.
Plugin/skill/marketplace renamed to beardrive (/beardrive:mount).
Module path is now github.com/runbear-io/beardrive — merge only after
renaming the GitHub repo, or go install breaks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHEUaYfFHhmDvqLYw74Ehz
- sfs-web (cmd/sfs-web, internal/webapp): read-only Obsidian-style web UI
serving a local folder (default) or an sfs remote; markdown rendering
with [[wikilinks]], task lists and tables, file downloads with ETags,
per-file provenance from the journals; added to goreleaser builds
- .sfs project file (internal/config): per-folder volume/remote/include
settings that travel with the folder, win over the global registry, and
never sync; daemon picks up edits live
- .sfsignore + include lists (internal/syncer): gitignore-style selective
sync with ! re-includes, applied symmetrically in scan and materialize;
newly ignored files stop syncing without being deleted anywhere
- Claude Code plugin (plugin/, .claude-plugin/): sfs skill, /sfs:mount and
/sfs:status commands, turn-boundary sync hooks (blocking pull on prompt,
async push on stop); installable via the repo's marketplace manifest
- CLAUDE.md and .claude project settings for Claude Code development
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHEUaYfFHhmDvqLYw74Ehz
One volume can be mounted at several folders (e.g. ./shared in multiple
repos defaulting to the same volume name). Previously the daemon pidfile
and materialization cache were keyed per volume, so mounts fought over a
single daemon and shared one folder-state cache. Key both by a mount ID
(hash of the absolute folder path); blobs, journals, and the lamport
clock stay shared per volume. Content now propagates between co-mounted
folders even offline. Also default remote polling 30s -> 10s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>