Adding a path to .bdriveignore only stopped future uploads: anything that
synced before the rule existed stayed on the hub forever, with no command
that removed it without deleting it from local disk on every device.
Two engine changes make an explicit removal safe:
- materialize's delete loop now consults the filter. A cached path absent
from the replayed target that the rules exclude is dropped from tracking
instead of unlinked — without this, any delete op for a now-filtered path
wipes every peer's local copy, which is the data loss this issue is about.
- the filter is reloaded mid-cycle from the pulled .bdriveignore, before
materialize. A peer receiving the new rules and the deletes they justify
in one batch would otherwise materialize with stale rules and the guard
would never fire. materialize's write side is split into materializeFile
so the ignore file can land on its own.
On top of that, Session.Prune journals a delete for every path the replayed
state still holds that the SHARED rules exclude — reconciling against the
replay, not the local cache, because a path filtered out in an earlier cycle
was dropped from the cache back then and is invisible locally today. The
rules are deliberately ignore-only: the include scope lives in each device's
own .bdrive/config.json and does not sync, so pruning against it would let a
narrow-scope device delete a whole-folder teammate's files.
Plain `bdrive sync` and the daemon are unchanged — pruning is never a side
effect of editing .bdriveignore.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`bdrive logout` and the beardrive skill both told users the device token
"stays valid until it expires — revoke it from the hub's device list".
All three parts are false: there is no device list page or route, device
tokens carry no expiry field, and logout makes no server call at all — it
only rewrites the local settings file.
Both strings now say what is true. The CLI note moves to a package-level
`logoutNote` const so `login_test.go` can assert it, plus SKILL.md's logout
row, mentions neither a device list nor expiry.
Correcting the strings only; the real device list + revoke route touches
the `AuthProvider` seam and is filed separately.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`bdrive init --shared wiki` wrote `include: ["wiki/"]`, which compile()
treats as an unanchored gitignore pattern — so any nested directory named
`wiki` synced too. Shared-subfolder mode is what people use to keep private
material out of a project, and it was silently widening the scope: 15 files
under .agents/, .claude/ and .gemini/ leaked into a real project from
.../detector/shared/ dirs.
cleanShared now emits "/wiki/", which fixes both callers (init --shared and
bdrive scope add). config.LoadProject anchors legacy single-segment entries
on read, so the existing mounts are fixed without a re-init — and that also
keeps `bdrive scope rm wiki` working against pre-fix configs, with a
belt-and-braces unanchored candidate key in scopeRemove for any config that
bypasses LoadProject.
Not touched: compile() itself, and no delete op for the already-leaked
remote files (BEA-20 — a delete would unlink teammates' local copies).
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(cli): init --shared accepts multiple subfolders (repeatable or comma-separated)
--shared is now a slice flag: `--shared wiki --shared docs` or
`--shared wiki,docs` sync several subfolders into one project
(include list ["wiki/", "docs/"]). The interactive scope prompt takes a
space- or comma-separated list. Entries resolving to ".", "", or ".."
error out — silently dropping them would widen scope to the whole folder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AubcaQscjcQucXwh578vqG
* docs(plugin): skills/commands propose multiple --shared folders at init
The init/install flows now scan for all knowledge folder candidates and
offer them as one --shared list (one project, one permission set), noting
that folders needing different access belong in separate projects.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AubcaQscjcQucXwh578vqG
* feat(cli): bdrive scope — add/remove shared subfolders without editing JSON
`bdrive scope` shows the include list; `scope add`/`scope rm` edit it from
the mount root. The daemon re-reads config each tick, so changes apply in
seconds. rm deletes nothing (newly filtered paths drop from the cache with
no delete op); removing the last entry is refused since an empty include
list means whole-folder sync. add onto a whole-folder project is refused
for the same narrowing hazard. Skill/README/docs updated; scope added to
the cli-sync diagram's command list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AubcaQscjcQucXwh578vqG
* docs+cli: scoping guide covers multi-folder --shared and bdrive scope; init hints on ignored --shared at resume
The scoping guide (the dedicated page for this feature) now shows
--shared wiki,docs and a "Change the scope later" section for bdrive
scope; setup-by-hand and project-files point at it. init resume with an
explicit --shared now says the flag is ignored instead of staying silent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AubcaQscjcQucXwh578vqG
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Access was binary and org-wide: any org member got full read+write on every
project. Now each project carries four ordered levels, resolved by one
resolver and enforced at one choke point.
- `projectPerm` (perms.go) replaces `projectAllowed`; `proj(level, h)` in
server.go gates every per-project route by the level it declares at
registration, so no handler grows its own check.
- `Project` gains Creator/Default/Perms. `Default == ""` means write, so an
upgraded hub behaves identically until someone edits permissions.
- Creator becomes the first project admin; org owners are implicitly admin
everywhere in their org and a grant naming one is refused, not ignored; a
project always keeps at least one explicit admin.
- Default `none` makes a project invite-only. A `none` member is treated
exactly like a non-member, including on create-or-join by name.
- Rename/delete move from org-owner-only to project `admin`.
- Both metadata backends persist it: the file store rides along, the SQL
store gains `project_perms` plus an idempotent ALTER for the two new
columns (migrate() had only ever created tables).
Client side, a refusal stops looking like an outage: `remote.ErrForbidden`
plus `Result.ReadOnly` (push refused → pull-only) and `Result.NoAccess`
(pull refused → paused, working folder untouched). Neither sets Offline,
neither loses a local op, and re-granting self-heals on the next cycle.
`bdrive status`/`sync` and the daemon (once, on transition) say which.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
go install github.com/runbear-io/beardrive/cmd/bdrive@latest skips the
release ldflags, so every module-built binary claimed to be 0.1.0-dev —
useless in beta bug reports. Fall back to the module version Go stamps
into the binary (debug.ReadBuildInfo) when ldflags didn't set one.
Found during the BEA-33 fresh-machine quickstart check.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): agent hooks never sync folders this device didn't opt into
The turn hooks decided "this folder is managed" from the mere presence of
.bdrive/config.json — a file designed to travel with the folder. Two holes:
- A config.json arriving via git clone / copied dir made one hook firing
silently mint a device identity, register the mount, create a volume
store, journal the whole folder, and inject the hub-link formula — on a
device that never ran init or login.
- `bdrive stop` only killed the daemon: the next agent turn's
`bdrive sync --hook` resumed a full sync cycle and kept injecting links,
and `stop --forget` was undone within one turn by registry self-heal.
Fix: one gate (`syncBlocked`) in the paths all hooks route through —
sync/sync --hook/read-log now require the mount to already be enrolled in
this device's mounts.json (read without ResolveMount's enrolling
self-heal) and not paused. Hook mode exits silently; plain `bdrive sync`
errors with a `bdrive init` pointer. New per-device paused marker in the
volume dir: set by `bdrive stop`, cleared by `bdrive init` (startSync).
Only init enrolls or resumes; folder moves still self-heal since
enrollment is keyed by mount id, not path.
Docs updated (README, SKILL.md, docs cli reference, CHANGELOG). Tests:
hook/read-log no-op + no-enrollment on unenrolled and paused mounts,
plain-sync refusals, stop→pause→forget regression, paused marker contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: architecture-diagram PRs must show before/after excerpts of changed classes
The "Architecture changes" PR section now names exactly what changed and
shows Before and After mermaid excerpts scoped to the affected classes and
their immediate relationships — never the full diagram (Before = merge
base). Convention updated in CLAUDE.md, architecture/README.md, and the
pre-PR hook's reminder text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: add cli-sync architecture diagram; widen diagram convention to the CLI
architecture/cli-sync.md draws the CLI and sync engine (cmd/bdrive +
internal/{syncer,store,journal,config,daemon,agenthooks}): the Session
cycle over Store/journal/remote, and the command layer with the new
syncBlocked opt-in gate, paused marker, and enrollment ownership. The
pre-PR hook and CLAUDE.md now watch these packages too, so CLI-side
structural changes trigger the before/after-excerpt convention the same
way server changes do.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: full-coverage architecture diagrams — overview, frontend, agentskills
Every application package is now drawn somewhere: overview.md (system
diagram — package map, device↔hub↔storage flow, agent surfaces, and the
private cloud/ repo as an external seam consumer), webapp-frontend.md (the
hub SPA's modules: App/HubApp/VolumeApp/Browser, the in-repo nav/router,
api layer, hooks, components), and agentskills added to cli-sync.md. The
pre-PR hook now watches all of cmd/, internal Go code, and frontend/src
(generated static/ excluded); CLAUDE.md and architecture/README.md state
the coverage rule: every code change lands in exactly one detail diagram's
scope, web/docs and cloud/ deliberately excluded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: PR bodies start with a TL;DR — max 5 informal one-liners
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The anti-lock-in story for cloud-hesitant users: export a project's
complete store (every device's journal + every blob, i.e. full history
and authorship) into a portable tar.gz, and import it as a fresh project
on any other hub — cloud → self-hosted or back.
The archive is simply the remote store layout plus a manifest, streamed
through the existing remote.Backend, so no server-side support is needed
and it works against every existing hub. Import verifies each blob's
content hash, rejects foreign tar entries, requires an empty target
project, and refuses journal-less archives. Reconnecting devices resume
exactly where they were, because their journals are byte-identical.
Docs: README + SKILL.md command tables, docs-site CLI reference section,
and a new step-by-step reference page (Migrate between hubs).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- init -f no longer prints 'daemon now keeps this folder in sync' after
the foreground daemon has exited
- one stdinIsTTY() helper (TTY or Cygwin pty) shared by init's prompt
gate and login's headless fallback — the two sites disagreed on Cygwin
- the daemon drops its remote backend when the device token changes, so
an account switch mid-run reconnects with the new credential instead
of pushing with the old one (httpBackend captures the token at open)
- whoami reports a settings read error instead of claiming 'not signed in'
- self-hosting/authentication and manual/setup-by-hand now describe the
automatic device-code fallback instead of presenting --device as required
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
BEA-7 launch-critical set from the onboarding audit:
- login: shells without a TTY auto-fall back to the device-code flow
(agents/CI/SSH no longer hang on the browser callback); a failed
browser open also falls back, and the waiting state hints --device
- bdrive --version now works (cobra root Version), same output as
bdrive version
- init prints a next-steps block: daemon auto-sync note, the
'bdrive init --project p-xxx' connect command for teammates,
bdrive log / bdrive share
- authorship: the daemon re-reads settings.json every tick so a
login/logout/account switch is reflected in op authorship instead
of stamping a stale identity forever; whoami now shows the
signed-in account and labels the git/OS author as the signed-out
fallback
Docs updated in README, plugin SKILL.md, and web/docs reference/cli.md.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Root --help still described the retired direct-to-bucket model (S3/GCS as
the sync transport); clients sync only through a hub now. And with
database: sqlite/postgres the hub startup line printed a projects.json
path that is never read — misleading for self-hosters checking where
their metadata lives.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
The hub's install guide told Codex and Hermes users to run four CLI
commands by hand, and the one people skipped — `bdrive hooks install` —
is exactly the one that makes files sync at turn boundaries. Hand the
setup to the agent instead, the way the Claude tab hands it to the
plugin.
- `bdrive skill install` (internal/agentskills, plugin/embed.go): the
binary now carries the beardrive skill and writes it to any agent that
reads SKILL.md — ~/.{claude,codex,gemini,hermes}/skills/beardrive/.
User-level on purpose: the skill is about the CLI, not one folder, and
a synced project folder should never carry it. Idempotent; refreshes a
stale copy after a CLI upgrade. Bare `bdrive skill` prints the table,
mirroring `bdrive hooks`.
- Guide's Codex/Hermes tabs are now a single paste, no terminal: the
prompt has the agent install the CLI, keep the skill, sign in, init,
and register hooks. The commands ride inside the prompt because these
agents ship no BearDrive knowledge (Claude's tab is terse only because
the plugin carries it). `login --device` there — a browser-callback
sign-in is invisible to an agent mid-turn, while the device flow gives
it a code and URL to relay. Plain commands live on in an "or run it
yourself" fallback.
- Docs realigned: README, SKILL.md, /beardrive:install, self-hosting.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bdrive log preferred Op.Author (git/OS identity) even when the op carried the
hub account (Op.User/UserName), so team history showed local git emails.
Found by the agent-onboarding live e2e: device signed in as e2e-bot showed
snow@runbear.io in log. Now: UserName → User → Author. bdrive status likewise
shows 'signed in as Name <email>' when a session exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbiaaVM2ACxeRi8ySG9ybc
Field report follow-up: an agent with a stale skill copy couldn't find
the gated URL after creating a file. Instructions rot; hook output is
computed fresh from the binary every turn. Claude Code's turn-start
pull hook now runs 'bdrive sync --hook claude-code', which:
- pulls as before, and stamps the session note from the event JSON
(replacing the sh/sed pipeline for the pull leg)
- emits the project's gated-link formula as UserPromptSubmit
additionalContext: whenever the agent mentions a synced file path in
prose, it appends the hub link on an emoji — `<path>` [🔗](<url>) —
path plain (it's the local path), hyperlink on the emoji only; code
blocks stay plain; bdrive share stays explicit-opt-in-public
Blind-tested: an agent given only the injected context decorated every
path mention correctly, kept the code-block command plain, and checked
files were synced before linking.
- hooks install now CONVERGES marker-identified groups to the current
shape (command/matcher/flags), so improvements reach existing
projects on reinstall instead of being frozen by the idempotency
marker; hermes same
- plugin: UserPromptSubmit → beardrive-pull.sh (stdout passes through);
version 0.3.0
- SKILL 'Share what you make' generalized to 'Link what you mention'
(URL formula documented for non-Claude platforms); install.md pointer
template updated
Never fails the turn: every error path in --hook mode is a silent
successful exit; offline still emits (links serve online teammates).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
New command printing a file's hub viewer URL: sign-in + project-org
membership required to open (the internal counterpart to bdrive share's
public links). Computed locally from the mount config — hub origin +
project id from the remote, path segments percent-encoded with literal
slashes; unsynced paths (ignored, or outside a --shared scope) are
refused so nobody gets handed a 404. --sync pushes first so a
just-created file's link resolves immediately; no arg = project home.
The plugin docs now instruct agents to include this link in their reply
whenever they create a shareable artifact (.md/.html/.csv/...) in the
shared folder, reserving bdrive share for people outside the hub:
SKILL.md command map + 'Share what you make' guidance, install.md root
pointer template + payoff step, README, CLAUDE.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
Field report: an agent session read a dozen-plus files via Bash
(grep/cat/tail/find) and Grep, but read heat showed almost nothing —
the read-log hook only matched the native Read tool.
- matchers broadened per platform: claude Read|Grep|Bash, codex
read_file|shell, gemini +search_file_content|run_shell_command,
hermes read_file|grep|bash; plugin hooks.json matches Read|Grep|Bash
- read-log is now tool-aware: shell events mine the command line for
existing files it names (redirect targets and flags excluded), grep
events mine the response for the files the matches came from
(content lines and filename lists), and listing tools (Glob, ls) are
deliberately ignored — seeing a file's name is not reading it
- hooks install upgrades a registered hook's stale matcher in place, so
re-running it after a binary upgrade rolls coverage out to existing
projects instead of being skipped by the idempotency marker
- docs: SKILL.md platform table + read-heat wording, install/init
commands, README command table
Note from the same report, verified not a bug: read-log resolves the
mount via the folder's own .bdrive/config.json (config.ResolveMount),
so a stale duplicate registry entry cannot swallow reads.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
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
Read telemetry per docs/design/read-heatmap.md: a ReadLedger over a new
batch-oriented MetaStore ReadRepo (file reads.json + SQL read_stats)
aggregates viewer and share reads into daily per-actor buckets, debounced
to visits, folded into all-time rows past retention. GET /api/p/<id>/heat
serves per-path counts (human/agent/share, distinct readers, last read) —
never identities. /store sync traffic and history blob views are not reads.
The viewer shows heat dots and read counts on folder listings and the file
meta line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
`bdrive hooks install` detects the agent platforms in use — Claude Code
(.claude/), Codex (.codex/), Gemini CLI (.gemini/), Hermes (~/.hermes/) —
and idempotently merges beardrive's turn-boundary sync hooks into each
platform's own hook config (JSON for claude/codex/gemini, YAML for
hermes), preserving existing hooks. All four pipe hook JSON with a
session_id, so one POSIX-sh hook command serves every platform: pull at
turn start, push after edits, changes stamped "<agent> session <id>".
Bare `bdrive hooks` prints the detection/registration table.
The beardrive skill now runs it automatically after `bdrive init`, and
/beardrive:install's hand-maintained settings.json block is replaced by
the command, so the hook content has one source of truth in the binary.
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
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
BearDrive is now hub-based only. Clients sync through a `bdrive web` hub
over https and never talk to object storage directly.
- Remove the `bdrive remote` / `bdrive remote set` command entirely. Wiring a
folder's remote is `bdrive init` (always a hub); switching hubs is
`bdrive login <url>` then re-`init`. Object storage stays as the *hub's*
backing store (`bdrive web s3://…`), never a client remote.
- Reposition README, CLAUDE.md, and the beardrive skill from
"no server required / direct-to-bucket" to hub-based; section 2 of the
skill is now "storing a hub's data" (server-side), and the stale
`init --remote` / `remote set` examples are fixed.
BREAKING: direct client-to-bucket sync is no longer supported.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
- New `bdrive logout` clears the saved token + account (folders untouched);
`--forget` also drops the remembered server. Switching hubs is
`bdrive login <new-url>` then re-`init`.
- `remote set --help` now explains the two remote kinds — object storage
(s3/gs/file) vs a bdrive hub (https://<server>) — with examples, instead of
leading with only s3://. (The https:// hub scheme was already accepted.)
- Docs updated (README, CLAUDE.md, SKILL.md).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
Wire a `database` block in bdrive web config: {driver: file|sqlite|postgres,
dsn}. Default file, unchanged (existing per-registry paths honored). sqlite
or postgres routes all five hub registries through the SQL MetaStore.
Verified: signup + project + invite round-trip survives a restart on BOTH
file and sqlite; the sqlite DB holds real relational tables/rows (not JSON
blobs). Docs: a "choosing a database" section (README), the persistence
architecture note (CLAUDE.md), and a config mention (SKILL.md).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
A hub on a public URL could be signed up to with any fake email. Make the
safe posture the default and turn the dangerous ones into startup errors.
Three supported postures:
- invite-only (NEW default): allow_signup defaults false. A valid org
invite link bootstraps an account even when self-signup is closed — the
only way in. pageLogin/pageSignup detect a /join/<token> target via
BuiltinAuth.InviteValid (wired to OrgDB.ValidInvite) and offer account
creation; signupInvited skips the domain/approval/verification gates and
activates immediately (the owner's invite is the vetting).
- approval-gated: allow_signup + require_approval (no SMTP needed).
- domain-restricted + verified: allow_signup + allowed_domains +
require_verification (needs smtp).
Hardening:
- BuiltinAuth.ValidateSignupPolicy (run at hub startup in web.go) refuses to
boot an open hub with no gate, or require_verification without a mailer
(the link would otherwise only reach the server log).
- handleAdminPolicy rejects enabling verification without SMTP; the UI
toggle is disabled with clearer copy in that case.
Tests: TestValidateSignupPolicy, TestInviteBootstrapsAccountWhenSignupClosed,
TestPolicyVerificationNeedsMailer. Verified end to end in the running hub
(outside-domain invitee onboards via a link; direct signup stays closed).
Docs updated (README, CLAUDE.md, SKILL.md).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
Fixes the two round-2 majors.
User: on mobile the per-file actions (Share/History/Upload/Download) are
now icon buttons in the header — reachable again (round 2 wrongly hid
them). Search tolerates simple English plurals (ideas→idea), the
no-matches state states what search covers, the sidebar shows the hub
brand instead of the raw device name, and history-row downloads carry a
download attribute. Logged-out loads redirect to sign-in from /api/config
instead of firing 401-ing API calls.
Admin: a hub-admin "Signup & access" settings screen (⚙ Admin in the
sidebar) toggles email verification and admin approval live — persisted to
auth.json and surviving restart — while the domain allowlist and admin
list are shown read-only (deliberately server-config-owned so a browser
session can't widen access). Pending approvals live on the same screen.
Config toggles are now *bool so an explicit config value pins the setting
each boot, else the UI-saved policy stands. Invite revoke confirms; role
change re-renders the panel.
Tests: policy persistence + reload, policy API admin-only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
Makes a self-hosted hub safe to expose on a public URL and operable
without hand-editing JSON — addressing the blocker/major findings from
the persona usability evaluations.
Signup gating (config auth block, all optional):
- allowed_domains: signup email must match (e.g. only @runbear.io)
- require_verification: email-link activation before sign-in (reuses mailer)
- require_approval: hub admins approve new accounts (admins list)
- brand shown on the sign-in page; allow_signup:false already hid Sign up
Accounts carry a Status (active/unverified/pending); non-active accounts
cannot authenticate.
Admin lifecycle (endpoints + web UI):
- org: rename, member role change, member remove (last-owner guarded),
invite list + revoke
- project: create (web), rename, delete (from the org panel)
- hub admins: approve/deny pending signups (sidebar bell + panel)
- org-wide public-share audit with revoke
UX: onboarding empty-state (explains invites, paste-invite + create-project)
instead of a blank sidebar; visible "Search ⌘K" button; toasts replace
blocking alert(); responsive layout with an off-canvas sidebar; joining
via #join now survives a logged-out click (token carried through login).
Web uploads are attributed to the signed-in account, not the server.
Login/signup are rate-limited per IP.
Tests: domain/verification/approval gates, auth rate limit, org+project
lifecycle, owner-only guards, invite→join→role→remove over HTTP.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
Rendered-markdown share pages get a small "Shared with BearDrive" footer
linking to the repo — the shell template only, so shared raw HTML is
still served byte-for-byte and the /s/ sandbox CSP is unchanged. All
/s/* responses now sit behind a per-IP token bucket (default 120 req/min
sustained + burst, tunable via "share_rpm" in the web config) so the one
unauthenticated surface of a hub can't be scraped into a free CDN.
Co-Authored-By: Claude Fable 5 <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
Share links (bdrive share <file>, or the web UI's Share button):
- Mint an unguessable public URL (/s/<token>) anyone can open — no
account. HTML renders as a page, markdown gets a standalone shell,
PDFs open inline; ?download=1 attaches.
- Sandboxed: /s/* responses carry CSP `sandbox allow-scripts` + nosniff
and never see auth cookies, so shared content's scripts run in an
opaque origin and can't touch hub sessions.
- Links serve the file's LATEST synced content and live until revoked;
--expires makes self-destructing ones; --list/--revoke manage them.
Re-sharing a file returns the same link. File-backed shares.json.
- CLI resolves the project by walking up to .bdrive/ from the shared
file, warns when the hub address is private (LAN-only links), and
hints when the file hasn't synced yet.
/beardrive:install (plugin command) — team onboarding driven by Claude:
- Ensures the bdrive binary, signs in (bdrive login), runs bdrive init
(whole folder or a shared subfolder like wiki/).
- Asks before appending a CLAUDE.md section that teaches agents to put
shareable artifacts in the shared folder and mint URLs with bdrive
share; asks before registering project-level hooks in
.claude/settings.json: blocking pull at UserPromptSubmit, async push
on PostToolUse Write|Edit — teammates sync with or without the plugin.
- Fix: the plugin hook script still checked for the old `.bdrive` file
and was a silent no-op since the directory change; now checks -d.
Tests: share creation gating, public access + sandbox headers, dedupe,
latest-content semantics, revoke, expiry, markdown/download variants,
list filtering, registry persistence. Docs updated (README sharing +
Claude Code sections, SKILL.md, CLAUDE.md).
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
One binary instead of two: cmd/bdrive-web becomes the `web` subcommand
(same flags and positional folder-or-URL argument). Measured cost of
carrying the webapp in the CLI: +1.4 MB on a ~56 MB binary (~2.4%) —
the cloud SDKs dominate either way. Drops the second goreleaser build
and the separate go install path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHEUaYfFHhmDvqLYw74Ehz
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
BearDrive is the product and repo brand (open source + future managed
cloud); sfs stays the CLI command, and .sfs/.sfsignore/~/.sfs conventions
are unchanged, so nothing breaks. Brew formula becomes
runbear-io/tap/beardrive (still installs the sfs binary).
Not included here (need the GitHub repo rename first): the Go module
path and repo URLs.
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>