69 Commits
Author SHA1 Message Date
82e23f2382 feat(webapp): copy a file's source to the clipboard (BEA-153) (#190)
You could read a doc in the hub and download it, but there was no way to
get its text onto the clipboard — the workaround was downloading a file
you didn't want, or select-all over rendered markdown, which silently
mangles what you paste.

Copy sits beside Download in the ⋯ menu and in the ⌘K palette, and puts
the file's raw source there: the whole file, frontmatter included, so it
round-trips with Download. With a version pinned (?v=) it copies THAT
version's bytes, the same rule Download already follows.

It fetches directly rather than reading the ["text", url] query cache:
TextView stores a bare string under that key and SniffView stores a
BlobText object, so a cache read would have refused .csv/.txt files
while working fine on the markdown a reviewer would test with.

copyText returns false instead of throwing, so the toast branches on it
— otherwise every http:// self-host gets a success message over an empty
clipboard.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 09:30:45 -07:00
b0029f914c fix(webapp): a background refresh never moves the reader (BEA-155) (#188)
* fix(webapp): a background refresh never moves the reader (BEA-155)

Reading a file was interrupted every so often by the viewport jumping back
to the top. Nothing polls the document — the 60s read-count poll does it:
`heatMap` sat in MarkdownView's meta-effect deps and that effect ended by
calling `onRendered()`, which the scroll restorer reads as "content landed"
and answers with `scrollTo(top: 0)`. Capped at 3 attempts per route, hence
"from time to time". `onScroll` then memoized 0, so Back also returned to
the top.

Two changes. The second is the reported bug; the first is why it can't come
back wearing a different hat:

  - The scroll goal retires itself the moment the reader scrolls, so no
    onRendered caller — present or future, honest or not — can move a reader
    who has taken over. Two clauses are load-bearing and each has its own
    test: scrollTo fires a scroll event of its own (so "moved" is measured
    against the goal, never against zero), and a page shorter than the last
    one makes the browser CLAMP the carried-over offset to the bottom, which
    is either the old page's offset arriving or a goal that doesn't fit yet
    — the exact case the retries exist for.
  - MarkdownView's effect splits in two: onMeta keeps its heatMap
    dependency, onRendered fires on [html, diagrams]. A metadata refresh is
    not a render. A mermaid diagram landing used to call onRendered never,
    so Back into a diagram-heavy file never got its late re-apply; now it
    does, which is what the retry budget was written for.

The state machine moves to src/lib/scroll.ts because the frontend suite is
`node --test` over pure TS with no jsdom — that is the only way this gets a
regression test at all. Browser.tsx keeps the DOM bits.

The other four onRendered callers are audited and deliberately left alone:
FolderListing's "Recent changes" feed, HistoryView, and SniffView/TextView
on ["text", …] all fire on content that genuinely changes the page height,
which is what the retries are for. MarkdownView was the only one whose
trigger was pure metadata.

No poll interval is touched, and every programmatic scroll keeps
behavior:"instant" — #content carries scroll-behavior:smooth, and an
animated restore would fire intermediate scroll events that the new guard
would read as the reader.

* fix(webapp): apply the scroll goal when it is armed, not only later (BEA-155)

Back landed at the top of the file instead of the offset it remembered —
already true on main, and the same restorer this branch is repairing, so it
lands here rather than as its own issue.

React runs CHILD effects before the parent's. A view calls onRendered from
its own effect, so by the time Browser's route effect arms the goal for the
new route, that call has already happened — against the goal of the route
just left, where the key check discarded it. The goal then sat armed and
nobody applied it: on PUSH the container's carried-over offset was clamped
by the shorter page and looked close enough to right, but on POP the
remembered offset was simply never restored.

Arm and apply in the same pass. Later onRendered calls still cover content
that grows after first paint, which is what the remaining budget is for.

* docs(architecture): lib/scroll.ts, the scroll goal Browser now delegates to (BEA-155)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:06:38 -07:00
4f04b78c71 feat(webapp): frontmatter moves to a collapsible side panel (BEA-154) (#187)
A doc's YAML frontmatter rendered as a table pinned to the top of the
reading column, so on anything with more than two or three keys the
document itself started below the fold. It is a panel beside the prose
now — a sticky rail on a wide window, a closed disclosure above the body
on anything narrower — and the reading column starts with the document.

The table was built on the server and handed to the client inside one HTML
string, so this is not a CSS change: markdown.go splits the parse
(frontmatterPairs) from the markup, /api/render gains an ordered
`frontmatter` field, and the viewer switches to RenderMarkdownPairs.

RenderMarkdown keeps its exact output — it is the public share page, and
every /s/ link ever minted serves it. shares_test now pins that, because
nothing else would have failed if a later cleanup moved shares.go onto the
pairs path.

Values cross the wire as literal text plus a `code` flag rather than
pre-escaped HTML, so the panel is ordinary React text nodes and never
touches dangerouslySetInnerHTML: "a value containing markup renders as
text" holds by construction.

The rail's breakpoint is 1400px, not the 1180px the plan named — 768 of
prose + 28 + 240 of rail needs 1036px of column, and at 1280 the reading
measure lost 110px, which is the squeeze the panel exists to avoid.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:55:29 -07:00
ae14d11ac3 fix(webapp): three readers on one network are three share opens (BEA-151) (#185)
The share actor key was token+"/"+IP, so every browser behind one NAT was
the same reader and the 10-minute visit debounce folded a whole office into
a single open — three personas each measured "1 open" for three readers, and
the panel's own copy promised the opposite.

The key gains a truncated hash of the User-Agent. ShareOpens already sums
across actor buckets and takes the max Last, so opens: 3 and an advancing
last_opened fall out with no aggregator change, no new field, and no change
to readDebounce. The UA is hashed because Record persists the actor through
ReadRepo into storage; token+"/"+IP stays the prefix so the existing leak
assertions keep covering the wider key.

The copy now states the rule the code implements, including its residual:
two people on one network in the same browser still count as one.

Deviation from the plan, deliberate: TestSec_Share_VisitorCannotInflateOrRedirectTheLedger
pinned "a visitor cannot split its own visits by varying the User-Agent".
That is now intended behavior, so the two UA rows move out of the
must-collapse set into an explicit assertion that they count separately.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 12:32:53 -07:00
20f59352ca fix(webapp): badge the credential the share gate already found (BEA-147) (#183)
* fix(webapp): badge the credential the share gate already found (BEA-147)

The hub could identify an AWS access key on line 3 well enough to refuse to
publish the file, and rendered that same key to every member as ordinary body
text. scanSecrets had exactly one caller — share minting — so the strongest
protection in the product sat on the rarest path and was absent from the path
every file takes.

The render response now carries the same finding, omitted when the file is
clean, and the markdown file view shows an advisory strip above the content.
Advisory only: nothing is blocked and nothing is redacted, because a member
who can open the file could already read the key.

The label vocabulary moves out of Browser.tsx into lib/secrets.ts, shared by
the badge and the share dialog, so the two surfaces cannot drift apart on the
wording of the same finding.

The ?sha= history render is scanned too — two lines, and it stops the badge
vanishing the moment you click into history on the file it was warning about.

Rule ids and line numbers only. The matched text reaches no response body and
no log line, pinned by a test on the new caller the way shares_test.go pins
the old one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(architecture): the credential scan gains a render-path caller (BEA-147)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:11:00 -07:00
52156f1b21 fix(webapp): a project URL by name opens the project (BEA-140) (#180)
Two personas guessed /wiki independently — the project name is what the
sidebar shows them, and the id (4c400e3f-…) never appears in the UI as
something to copy. Both got a page that argued with itself: the correct
project's chrome, breadcrumb and full file tree, with a body reading
"Project not found. This project doesn't exist, or you're no longer a
member." One of them owned the project.

Both halves land in the same place, and the net effect is one conditional
removed. `projectMissing` becomes an early return:

  - A first segment that names exactly one of your projects, matched
    case-insensitively on the DECODED segment (route.project is the
    still-encoded slice, so a name with a space would never have
    matched), redirects to /<id> with the rest of the URL — path, view,
    target, filters, version — carried along. Exactly one: ProjectDB
    names are scoped per organization, so a viewer in two orgs can hold
    two projects called "wiki", and the not-found page is the honest
    answer there.

  - Anything else renders the not-found body in a shell with NO tree —
    the same shape the `!current` branch already uses — so no other
    project's files sit beside a body denying the one that was asked
    for. The copy names the segment and drops the "no longer a member"
    claim, which was told to readers who may never have been members.

The `current` fallback chain is untouched: "Back to <project>" still
points at it. With the early return in place, none of the four redirects
below can be reached on a missing project, so the `if (!projectMissing)`
wrapper is gone and its reason now lives in the return.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:40:16 -07:00
d4fd7eb372 feat(webapp): say what a conflict copy is, where a reader meets it (BEA-128) (#177)
A concurrent edit is preserved as `<name>.bdrive-conflict-<device>-<utc>`
— the guarantee the whole shared-folder promise rests on. Until now that
promise appeared in the README, the docs and syncer.go, and nowhere in
the hub: a conflict copy was an ordinary row with an alarming name, and a
user could only learn what it was by reading the README.

conflictName is a pure function of the path, so the frontend recovers the
device and the moment from the string alone — no server route, no journal
field, no request. lib/conflict.ts holds the parser (anchored suffix, a
strictly narrower match of the Go convention; anything malformed is null,
never a throw), the listing marks the row, and ConflictBanner explains the
file and links the version that kept the original name.

History and the Dashboard stay out, per the spec's stated cut.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:09:43 -07:00
31db19b9c6 fix(webapp): carry the hot-and-stale warning to the file and folder views (BEA-119) (#175)
* fix(webapp): carry the hot-and-stale warning to the file and folder views (BEA-119)

The Dashboard flagged archive/retired-spec.md as read-a-lot and
unmaintained, then the file's own page served it with raw counts and a
raw date and left the reader to do the staleness arithmetic. The warning
existed on the one screen nobody opens before trusting a doc.

HOT_READS, STALE_DAYS and the danger predicate were module-private to
Insights.tsx, so no other surface could reach the verdict — even though
both inputs (heatMap, Node.time) were already in hand on both of them.
They move to lib/heat.ts, whose own header says every read-count surface
shares one arithmetic, and Insights.tsx imports them instead.

The predicate takes (reads, days) rather than a heat entry: only the
Dashboard has a reader lens, so it keeps passing its lens-filtered count
while the file and folder views pass heatTotal.

- file page: "⚠ stale · last changed 7 months ago", leading the meta line
  because #meta is nowrap + ellipsis and a trailing warning is the first
  thing a narrow window eats
- folder listing: ⚠ beside the heat dot, files only (a folder's heat is a
  subtree sum with no single mtime), with a real aria-label rather than a
  hover-only title
- no threshold change: the Dashboard flags the same set, pinned by both a
  unit test on the boundaries and an e2e test on all three surfaces

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(architecture): heat.ts now owns the hot-and-stale verdict (BEA-119)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:15:22 -07:00
Snow Lee (Sungwon)andGitHub 333d1fb8e5 fix(cli): bdrive log sorts by when a change arrived (BEA-112) (#174)
`mv a.md b.md` produced two rows minutes apart: the delete carried the
rename's time, the put carried the original file's mtime, so a file that
appeared seconds ago sorted below the fold of "what changed since
yesterday" — the question `bdrive log` exists to answer.

SortForDisplay now orders by CommitTime (when the change was journaled),
tie-breaking on DisplayTime so one scan still reads by the files' own
edit times. The write time is still shown, appended as `written <time>`
when it lags the commit by more than a minute — a rename, or an old
document added today — rather than silently replacing the column.

DisplayTime and both of its security clamps are untouched. CommitTime
carries the same clamp: an op stamped after this machine's clock cannot
date itself, so it sorts last rather than first.

One deviation from the plan: it assumed a scan shares one commit time,
but nextOp stamped time.Now() per op, so the tie-break never engaged and
one scan sorted in walk order. A scan is now one commit instant — order
inside the batch is already carried by Lamport and Seq, which
journal.Less reads first, so replay is unaffected.

journal.Less, Replay, LogEntries' causal order and the op format are
unchanged. The hub's History is a separate path and still orders a
rename by write time.
2026-08-18 22:10:02 -07:00
3fbe1252db fix(webapp): say that a read count includes your own views (BEA-61) (#173)
* fix(webapp): say that a read count includes your own views (BEA-61)

"14 human reads" counted the author's own browsing, and no surface said so.
Snow's call was to keep counting them — so this is disclosure, not ingest:
recordRead and /heat are untouched.

One HEAT_DISCLOSURE constant beside heatText in lib/heat.ts, consumed by all
four surfaces that print a count. The file header carries it as hover text
plus an .sr-only span rather than visible text — #meta is nowrap + ellipsis,
so anything appended there is the first thing a narrow window truncates away.
The folder page says it once out loud, covering the summary and every row,
and the heat dot keeps it in title and aria-label for a row met on its own.
The Dashboard folds it into the caption already there, both scope branches.

A unit test pins the constant as the only copy of the sentence in src/ —
"defined once" is the acceptance criterion no browser test can see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(architecture): note HEAT_DISCLOSURE in the frontend lib diagram (BEA-61)

The diagram enumerates heat.ts's exports, so a new one belongs in it — and
the note says why the constant sits beside the arithmetic instead of in the
four components that print it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 18:04:15 -07:00
c3b9fa5858 fix(cli): status looks at the folder, not just the cache (BEA-106) (#171)
`bdrive status` answered from the state cache and the journal and never
looked at the working folder. With the daemon stopped, an edit nobody has
scanned is in neither — so the one command that answers "is this folder in
sync?" reported `pending: 0` with the change sitting right there. A wrong
"you're clean" is worse than no answer.

syncer.Drift is a sibling of Explain/SyncedFiles with the same contract:
loadFilter + walkFolder + the scan's own size+mtime compare, and nothing
else. status prints it as a `local:` line, distinct from `pending` — they
are different states and a change can be in either or both.

The load-bearing property is that it stays a pure read. status is what
someone runs when sync is stuck; a version that scanned-and-committed would
change what it was asked to describe, and would write ops from a command
nobody expects to write. Pinned by a test hashing the device journal and the
state cache before and after.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:04:14 -07:00
495f72c43d feat(cli): bdrive stale — flag the docs your code has outgrown (#165)
Staleness today is age and only age: Insights.tsx's STALE_DAYS = 30 flags a
fresh doc nobody reads and misses the one that costs a team — a doc edited last
week describing code that moved yesterday.

`bdrive stale` measures the other thing. It scans synced markdown for
references to other synced files and reports every doc that links to something
written after the doc itself.

It is `bdrive grep` with a different per-file predicate, and keeps that
command's whole posture: LoadProject not ResolveMount (a read must not enroll
the device), a Stat-guarded store.Open (a read must not create a volume), no
session, no flock, no network, safeField on everything printed.

The one place it must not copy the obvious approach is dating a file.
materialize never calls os.Chtimes, so a peer's January edit carries this
device's mtime — on a freshly cloned machine every mtime is within seconds of
every other, and mtime comparison would report nothing on exactly the machine
that most needs the answer. Dates come from the journal: st.AllOps() folded to
the max syncer.DisplayTime per path, which also drops a forged year-9999 stamp
rather than dating that path to year 1 and flagging everything that links to it.

Resolution is the filter — a URL, a ../ escape, a made-up path is silently
ignored — so a .bdriveignore rule or a narrowed `bdrive scope` excludes a file
here exactly as it excludes it from sync.

Exit status is 0 whether or not anything is stale. grep's "1 means nothing
found" convention inverts here: it would fail on a clean project, and this is
advisory in the same sense the agent hook's context is.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:09:47 -07:00
Snow Lee (Sungwon)andGitHub a3dfa73fef Catch a credential when it syncs, not only when you share it (#162)
* refactor(secrets): lift the share-time credential rules into internal/secrets

The rules only ever ran on the rarest path a file takes. Moving them out of
internal/webapp is what lets internal/syncer run the same six rules on the
path every file takes, without inverting the dependency.

Pure move plus one addition: Label(), the six human strings that until now
lived only in the frontend's SECRET_LABELS — so 'bdrive share' stops printing
a bare rule id where the web dialog says 'an AWS access key'. Rule ids and the
rule/line JSON tags are unchanged: Browser.tsx keys off them, so they are a
wire contract.

* feat(sync): warn when a synced file looks like it holds a credential

The six share-time rules now run on the path every file takes. A file with an
AWS key in it used to ride a normal sync to the hub, to every teammate's disk
and into every future agent's context with no badge and no warning — while the
Share dialog one click later blocked that exact file.

Warn, never block: the op is journaled and pushed exactly as before. A hold arm
would mean a false positive silently parks someone's changes, and it would
break the cycle's degrade-to-offline posture.

- scan() reads the blob PutBlobFile just wrote (the bytes that were actually
  journaled), only on the branches that wrote one — an unchanged file is still
  never re-read.
- Findings persist per path in secrets-<mount>.json, merged rather than
  replaced: nearly every cycle scans zero files, and a whole-set rewrite would
  erase the warning seconds after it appeared. Fixing the file clears it.
- bdrive status grows a secrets block; the agent hook appends one advisory
  sentence. Rule ids and line numbers only, never the matched bytes.
- SaveSecrets failing logs and continues: advisory telemetry never gets a veto
  over convergence.

* docs: the credential check now runs on sync, not only on share

README, the CLI reference and project-files get the new bdrive status block
and the warn-never-block posture, with the three limits stated (checked when
it changes, first 1 MiB, writing device only). Diagrams: internal/secrets is a
package of its own in the overview, secretLog joins the sync engine, and the
share-gate class notes that it no longer owns the rules.

* test(sync): assert an unchanged file is never re-read for credentials

The check must ride the branch that already reads the file. Clearing the record
by hand and cycling proves it: a scan that re-read unchanged files would put the
finding back, and the daemon's 3-second tick would pay for it on every file.
2026-08-18 15:08:44 -07:00
398f30d64b fix(webapp): a wikilink is a real link, not a wiki: string (BEA-136) (#151)
[[guide]] rendered as href="wiki:guide" — a pseudo-scheme no browser can
resolve. The delegated click handler rescued a plain left-click, so the
feature looked fine until someone copied the link, middle-clicked it, or
opened it in a new tab and got a dead string.

Resolution moves from click time to transform time: transformHTML (the
pass that already rewrites this HTML before the mount) matches the target
against flatFiles and writes the real urlForPath() URL, plus a data-wiki
marker. A wikilink matching no file loses its href entirely and renders as
.wiki-missing, so no "wiki:" survives into the DOM either way.

The matching rules didn't change — they moved into a pure resolveWiki() in
util.ts, where node --test covers the whole matrix without a browser.

The consequence to get right is the click: real hrefs mean a plain click
must be intercepted (or it does a full document load) and every modified
click must be let through (or the fix buys nothing) — the same rule
nav.ts:linkProps applies everywhere else. The guard sits above both
branches, so cmd-clicking a relative markdown link now opens a tab too
instead of SPA-navigating the current one.

markdown.go is unchanged: wiki: stays the marker the server leaves behind
because RenderMarkdown has no file tree. /s/<token> share pages keep their
dead wikilinks by the spec's decision — the target isn't part of the share.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:48:34 -07:00
eb01953729 feat(webapp): undo a whole agent run from the run card (#156)
* feat(webapp): undo a whole agent run from the run card

The run card was grouped for this and stopped one button short: every row
inside it carried an action, the header carried none, so reverting a bad run
meant clicking file by file and hoping you got them all.

POST /api/p/<id>/undo-run works out, for every path the run touched, the op
that puts it back — a put at the pre-run blob, or a delete for a file the run
created — and writes them all in ONE journal append. That is the atomicity
argument, not an optimization: one Put of one object either lands or it does
not, so there is no half-undone run to report. appendOps is the batch write
every path in the package now goes through; appendOp is its single-op call.

Selection is by the journal an op was READ FROM, never op.Device — that field
is arbitrary JSON any member with write access can put in their own journal,
and the card attributes rows the same way. The note form additionally requires
an empty Session, because runs.ts can never file a session-carrying op under a
note-keyed card.

Append-only throughout: the run's own ops are never edited or removed, so
one-writer-per-journal and deterministic replay both survive. The undo's ops
carry a note naming the run, so the undo is itself a run card you can undo.

The confirm asks the server for the file list rather than deriving it from the
loaded feed (paged and filterable, so a client-computed list is wrong exactly
when the run is old), lists every path with its action, and names the one thing
that can burn someone: a file a teammate changed after the run is reverted too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(webapp): the undo confirm names the paths it will not write

planUndo already refuses a path the hub's own upload door would refuse — a
peer can push one under .bdrive/ or with a control character in it — but the
dialog listed only what the undo WOULD do, which reads as "all of it".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:04:35 -07:00
6f0f474903 feat(hub): count file changes and headless users server-side (#164)
The frontend's PostHog tracker sees everything a person clicks, but a
device syncing through /store/* never loads a page — so an agent editing
files all day was invisible, and "number of file changes" and "daily
active users" both undercounted by however much of the product runs
headless.

One event, files_changed, from every write door: sync, upload (relay and
direct commit), remove, restore. Its distinct_id is the same email
analytics.ts identifies with, so a person on a laptop and a browser is
one user, and its puts/deletes properties sum to the change count.

The count comes from ops the hub has not stored before, not from the
request body: a device PUTs its WHOLE journal every cycle, so counting
the body would re-report the device's entire history every ten seconds
and the metric would climb while nobody edited anything.
journalKeepsItsOps already parsed the stored journal for the append-only
check and threw the sequence away; it returns storedMax now, so this
costs no extra read. Blob PUTs are deliberately not change events —
content-addressed storage skips a blob it already holds, so blob writes
undercount edits while ops are exact.

No SDK: posthog-go would ship a tracker inside every self-hoster's
binary, which is the exact thing the frontend avoids by loading
posthog-js from a CDN only when a key is configured. Capture is one JSON
POST, on its own goroutine, that does nothing when Analytics.Key is
empty — an OSS hub still contacts nobody.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:50:25 -07:00
Snow Lee (Sungwon)andGitHub 3de8590b6b feat(sync): gzip the sync wire, without touching what a hash means (#160)
Nothing on the /store/* wire was compressed, while the corpus it carries is
markdown and source. Compression lands as a pure transport concern: content
addressing, the storage layout and the journal format all stay over the
uncompressed bytes.

The two legs are not symmetric. Pull needs no negotiation — net/http already
sends Accept-Encoding: gzip and inflates transparently — so devices built
before this get it the day the hub ships; a real pre-compression binary
receives 19,958 bytes for a 148 KB corpus (7.4x) with no client change. Push
is negotiated through sign()'s accept_encoding, because a gzip body posted to
an old hub would be stored under the sha256 of its plaintext.

The hub inflates ABOVE spool — the sha a key promises, the ops a journal
carries and the size that gets billed are all plaintext properties — and the
inflate is bounded at 256 MiB, because Content-Encoding severs the
one-wire-byte-one-disk-byte relationship that made spool safe unbounded. The
presigned direct-to-storage leg stays raw and is asserted to.

Known deployment caveat: a compressed push clears ContentLength, so it goes
out chunked where every push was sized before. A reverse proxy that buffers or
rejects chunked request bodies would fail pushes (degrading to Offline and
retrying, not losing data).
2026-08-13 12:20:36 -07:00
edfe46c0aa post_sync: run a local command when teammates' changes land (#163)
Inbound sync was invisible to the machine it landed on — a local index,
cache or notifier had to poll. A `post_sync` command in the folder's own
.bdrive/config.json now runs once per cycle that applied peer changes,
with the batch as JSON on stdin.

The batch rides out on a new Result.Inbound rather than the inbound
spool: DrainInbound is destructive and `bdrive sync --hook` is its only
consumer, so a second drainer would silently empty the agent's
"teammates changed X" context. Both are kept, and both comments now say
why.

Cycle becomes a thin wrapper over cycleLocked so the hook is spawned
after the volume flock drops — a property of the code shape, not a rule
each of the seven call sites has to remember.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:04:23 -07:00
0dd474baab Delta sync: large files move as content-defined chunks (#161)
* feat(sync): delta sync — large files move as content-defined chunks

Files over 4 MiB push as chunks/<sha256> pieces plus a manifests/<sha256>
chunk list keyed by the whole file's hash, so Op.Blob alone locates it and
the journal format is byte-identical. A 1-byte edit to a 20 MiB file now
transfers ~2 MB instead of ~21 MB, both directions; chunk boundaries come
from a rolling hash (restic/chunker), so front insertions stay cheap.

The hub reassembles whole blobs on demand (spool, verify, backfill, serve),
which is the entire backward-compatibility story: old clients ask for
blobs/<sha> and never learn anything changed. Proven by e2e tests that build
the real pre-change binary from the pinned merge-base commit.

The push skip-proof is one Exists per chunk — three cheaper proxies (local
basis, manifest existence, stored manifest content) each proved false or
forgeable across four CTO review rounds and are recorded in the code
comment. Hub-side, manifests are write-once and must name only chunks the
store holds; reassembly is bounded at 256 MiB against amplified manifests.

Also: per-file sync ceiling 32 -> 100 MiB; import refuses archives whose
journals reference content they do not hold (--allow-incomplete overrides).

Deploy hubs before clients: old hubs refuse chunk keys (push degrades to
offline-retry), and old clients cap reads at 32 MiB so 32-100 MiB files
report "blob corrupt on remote" until the client upgrades.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6nqxi5a9qcENmJvrgBJF7

* fix(test): fetch the pinned pre-delta commit on shallow CI clones

buildOldBinary archives the pinned merge-base sha, which a fetch-depth-1
actions/checkout does not have — all three old-binary e2e tests failed in
CI with exit 128 while passing on any full local clone. On archive failure,
fetch just that commit (--depth=1, one object; actions/checkout persists
credentials so the in-job fetch works) and retry. Verified against a real
GitHub shallow clone: archive fails, the single-sha fetch succeeds, archive
then yields the pre-delta tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6nqxi5a9qcENmJvrgBJF7

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 07:18:37 -07:00
33ca0caeab fix(sync): connecting a folder adopts the project's files instead of forking them (#159)
A device's first cycle on a volume was treated as a concurrent edit for every
path the project already held. Whichever side's clock happened to sort higher
won -- so a joiner's seeded .bdriveignore or agent-written AGENTS.md could
replace the team's -- and the loser landed beside it as a
.bdrive-conflict-<device>-<time> file.

A first cycle is a join, not an edit. Cycle step 1b holds the scan's ops back
over the pull and demotes any whose path the project already holds to lamport
0, which sorts under every op a project can carry (scan's clock starts at 1).
The project's version then wins deterministically on every device, and
conflictCopies skips those ops the way it already skips re-asserted ones. The
local content is still journaled and pushed, so it stays in History and
`bdrive restore --list <path>` can bring it back. Reported as `adopted: N`.

Concurrent edits after the join are untouched.


Claude-Session: https://claude.ai/code/session_01GLCyEWP2XZhYjBssvdCDQm

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:10:10 -07:00
94091a8795 fix(hub): device binding is a provider contract, not a BuiltinAuth field (#147)
ownJournal refuses a journal write unless the device id is bound to the
caller's account, for EVERY provider — it asks only whether s.Devices is
nil. The only thing that creates that binding is DeviceRegistry.Bind, whose
only caller is BuiltinAuth.finishLogin, and that hook was wired behind
`if a, ok := s.Auth.(*BuiltinAuth); ok`.

So a hub running a managed AuthProvider — the deployment the seam exists
for — bound nothing, ever, and refused every journal push from every device
forever. Everything around it read healthy: /api/auth/me answered, project
permissions said write, and blobs (content-addressed, so ownerless) uploaded
fine. Only the journal PUT died. Signing in again could not help, because
signing in was the step that was supposed to bind.

UseDeviceBinder moves the hook onto the AuthProvider interface — a breaking
change for an out-of-tree provider, deliberately, so one that ignores a
precondition of a gate the hub enforces for it does not compile. The hub
cannot bind on the provider's behalf: a bind must be reachable only from a
completed authentication, and Authenticate reports who a request is, never
which credential class it presented. A device token still cannot reach a
bind; no new door was added, and every /store/* door still creates nothing.

Bind also reported success for a row the store had refused — observeLocked
logged the write failure and swallowed it — so a login could hand back a
token whose every push was then denied, with nothing in the hub explaining
why. It now propagates and the login fails honestly.

And a hub that refuses a journal write while holding no binding at all logs
that its provider is not calling the binder — the sentence that would have
ended this investigation on day one instead of day two.

Tested by driving the real binary against a hub with a managed provider
(cli_provider_e2e_test.go), which is the configuration no test in this repo
covered and the reason this shipped: every existing test used BuiltinAuth,
where the wiring happened to work. Both directions are pinned — a provider
that binds pushes, one that ignores the binder reproduces the reported
symptom exactly.


Claude-Session: https://claude.ai/code/session_01GSHsQU4pBCzKkPyPeXSwTm

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:23:34 -07:00
922886b949 fix(sync): a refused push stays refused, and says why (BEA-403) (#146)
A device whose journal push the hub 403s reported healthy sync between
every pair of remote passes, and never showed the hub's reason for the
refusal — so a user whose device was not registered to their account
re-ran `bdrive login` (which the message told them to), re-checked their
project permissions (write), and had nowhere left to look.

Two causes, both local to the client:

- Cycle recomputed st.Access from scratch at the end of every pass,
  including the daemon's cheap local-only ticks that never reach the hub.
  Three of those run between remote passes, so the daemon alternated
  "read-only on this project" / "access restored; syncing normally" every
  few seconds and `bdrive status` reported OK moments after a refused
  push. Now each leg records its own verdict — pull clears no-access, push
  records read-only or clears it — and a cycle that asked nothing leaves
  the last answer standing.
- The hub's own sentence was summarized into "read-only (pull only)",
  which describes the STATUS CODE. It is the only thing that tells a
  device-registration refusal from a project the user really is a reader
  on. It now rides in SyncState.AccessReason and Result.Reason(), printed
  by `bdrive sync`, `bdrive status` and the daemon log, and dropped unless
  it passes journal.SafeText — hub text reaching a terminal.

The hub's refusal also now names the upgrade: the binding is made by the
login request naming its device, which a CLI older than the gate does not
do, so "run `bdrive login`" alone sent that user in a circle. Hub and CLI
deploy separately, so the skew is the expected state right after the gate
ships.


Claude-Session: https://claude.ai/code/session_01GSHsQU4pBCzKkPyPeXSwTm

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:33:26 -07:00
5623113ff7 feat(sync): agents hear what teammates changed before they overwrite it (BEA-127) (#144)
A teammate's agent rewrites a file and yours never hears about it — the only
trace is a `.bdrive-conflict-*` nobody opens. Now the turn-start hook names
the paths that arrived since the last turn: "re-read before editing".

The record lives in a spool (`internal/store/inbound.go`), not in Cycle's
Result, because the daemon usually materializes a peer's change seconds
before the turn starts — so the hook's own cycle sees nothing. materialize
appends every path it writes or removes, `bdrive sync --hook` drains it after
its cycle and renders it under each mount's own prefix (stripping the session
subpath when the run is inside a mount, dropping paths outside it).

Advisory only: nothing blocks, nothing prompts, no per-Write remote call. The
spool is capped, 0600, in the volume dir, and best-effort everywhere — a
spool failure never fails a cycle or a turn.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 14:33:20 -07:00
Snow Lee (Sungwon)andGitHub 2875e033be feat(webapp): mermaid fences render as diagrams, in the viewer and on share pages (BEA-91) (#143)
A ```mermaid fence rendered as a wall of `graph TD` source on both surfaces.
It now renders as an SVG in the hub file viewer and on public /s/<token>
markdown share pages.

Mermaid ships inside the binary (no CDN, so air-gapped self-hosters keep
working) and is imported lazily: a document with no fence downloads none of
it. A fence that doesn't parse — the common case for hand-written wiki
diagrams — keeps today's <pre><code> plus a small note, and one bad fence
never stops the good ones beside it. A blocked or offline chunk lands in the
same place.

The share page is the harder half: it is server-rendered Go HTML with no
JavaScript, and its `sandbox allow-scripts` CSP makes the origin opaque, so a
module script and every import() it makes arrive with `Origin: null`. The CSP
is unchanged and gains no allow-same-origin; instead the real-asset branch of
frontend() now sets Access-Control-Allow-Origin, which only ever touches files
that are already public and cookie-less. The script tag itself is injected
only when the rendered document actually contains a fence.

Also fixes an embed bug this change surfaced: `//go:embed static` silently
skips names beginning with `_`, and Vite's first shared chunk is
`_commonjsHelpers-<hash>.js`. The build passed, the commit looked right, and
the served app was blank. It is `all:static` now, with a test that every file
on disk is in the binary.
2026-08-10 14:18:18 -07:00
4031495c81 feat(cli,docs): say that agent skills sync, and refuse ~/.claude as a mount root (BEA-117) (#138)
`.claude/skills/**` has always synced — deliberately, per the reservation
rule's own comment — but the only sentence saying so sits under the heading
"What beardrive does not sync". Nobody knows.

Track B, the one real bug: `bdrive init ~/.claude` was accepted. The
reserved-path rule matches ".claude/settings.json" on its directory segment,
so at that mount root the file is bare "settings.json" — reserved by nothing —
along with .credentials.json and every saved session under projects/. New
exported config.AgentConfigDir folds the keys of agentHookConfigs the way
ReservedDir folds (case, trailing dots), and init refuses before any network
call or file write. Only that direction leaks: a mount CONTAINING ~/.claude
still sees .claude/settings.json, reserved at any depth.

Track A, the content job: a README Features bullet stating the positive claim,
a 7th use-case page (plus its astro.config.mjs sidebar entry, without which it
is invisible), and a `skills` template appended last to the registry so `docs`
keeps the RECOMMENDED badge. The embed directive becomes `//go:embed all:files`
— a plain pattern drops dot-prefixed paths silently, so the template whose
whole payload is .claude/skills/<name>/SKILL.md would have shipped empty.

templates_test.go's every-directory-holds-a-file rule now marks ancestors, not
just the direct parent: skills is the first template more than one level deep,
and the rule was stricter than its own stated reason (an intermediate
directory on the way to a file is not empty).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 05:04:12 +09:00
d3d92bf904 feat(webapp): refuse to share a file that looks like it holds credentials (BEA-111) (#137)
Minting a share link ran zero content checks: a file holding an AWS-shaped
key became a public URL on one click, and the CLI printed nothing but the
link. handleShareCreate now reads the first 1 MiB and runs six anchored
rules between the synced-path check and Shares.Create, answering 409 with
rule ids and line numbers unless the request carries confirm: true.

The matched text never leaves scanSecrets — not into the body, not into a
log line. TestShareSecretNeverEchoed greps both for the planted string,
because a 409 body is the easiest place in this codebase to leak it.

Both callers carry the override, since the gate alone would turn any false
positive into a hard block with no way out: `bdrive share --force`, and the
browser's Share-anyway dialog on modalConfirm (no new component). A path
that already has a live link skips the scan — its content is public
already, so withholding the URL protects nobody — but alreadyPublic drops
links whose creator left the org, since those 404 at /s/ and would
otherwise wave a secrets file straight through.

A failed blob read is 503, not a silent pass: the repo's "degrade rather
than fail" posture is for sync cycles, and a check that skips itself on a
storage hiccup is the false confidence this exists to remove.

Every user-facing string says the file was checked at the moment you shared
it. A link serves the file's LATEST content forever, so a key written into
an already-shared file is never caught — that open loop stays open, and the
copy is the only thing stopping v1 from claiming otherwise.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 04:33:05 +09:00
594a027c15 feat(cli): bdrive grep — search the text inside the files a project syncs (BEA-99) (#136)
The ⌘K palette searches file names, projects and actions; nothing in the
product searched file contents. Three personas independently typed a phrase
that lives inside a synced file and got "No matches".

`bdrive grep <pattern> [folder]` searches the working folder — RE2 or -F
literal, -i, -l, -n (default 200, 0 = all), output `path:line: text`, exit 0
on match and 1 on none.

It searches exactly what the project syncs, via a new syncer.SyncedFiles that
wraps the existing walkFolder: the one copy of the sync predicate, so an
ignore rule or a narrowed scope excludes a file from search the same way it
excludes it from sync, and .bdrive/ state can never surface. Not Explain,
which countFiles every pruned dir — a grep in a repo with node_modules/ would
walk it in full for a count it discards.

A read stays a read: LoadProject, not ResolveMount (no registry self-heal, no
device enrollment), no session, no flock, and the volume store is opened for
IgnoreAccepted only when it already exists, so a search creates nothing.
Both the path and the matched line go through safeField — a matched line is a
teammate's file content, the widest version of the surface that function
exists for.

The hub-side content index stays deliberately unbuilt; the issue records its
cost. ROADMAP's "Search across the hub" line is reworded rather than removed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 04:27:20 +09:00
Snow Lee (Sungwon)andGitHub 5f1ac98dae feat(hub): see what each agent session read, not just what it changed (BEA-98) (#135)
History showed what an agent run CHANGED. What it read lived in a daily
aggregate with no session dimension, so the two could not be joined and
nobody could answer "when my agent answered, what did it look at — and was
it the fresh version or archive/retired-spec.md?".

The join is one string carried through four places: hook -> spool -> hub ->
run card. A run card now marks each change the run also read, lists the
files it read and never touched, and says on screen why a read can be
missing.

The three landmines the issue asks be named here:

1. Op.Note is USER-SETTABLE (`bdrive sync --note`), so joining reads to
   writes on the note string would let any member with write access forge a
   note that collides with a teammate's run card and hang their reads off
   it. Fixed by adding journal.Op.Session — set only by `bdrive sync
   --hook`, never by --note — and joining on that. The note stays settable
   and stays untrusted; the join simply never reads it. Op.Session is
   additive JSONL and, like Mtime, is never an input to Less or Replay, so
   replay determinism is untouched and older ops carry "".

   The read half has the same hole one step further on: POST /reads takes
   the session id from the CLIENT, so a member could report reads under a
   teammate's session and paint files onto their card. Every session row is
   therefore pinned to the ownsDevice-validated device, and the query
   requires ?session= AND ?device= together — a forged row can only be found
   under the forger's own device, which MayActAs guarantees is never
   somebody else's.

2. BUCKET CARDINALITY. Putting the session in the read_stats key would take
   a 2k-file project from ~2k to ~100k rows/day, into a table ReadLedger
   loads whole at boot and full-scans on every heat request, hub-wide — so
   it would slow the Dashboard for projects that never ran an agent. This
   is the escape hatch the spec itself names, taken up front: session rows
   live in their own read_sessions repo, outside ReadLedger.byKey. No
   read_stats PK migration, no change to the resident-row count, ?by=device
   byte-identical. They get their own retention (session_retention_days,
   default 30) which DELETES rather than folds — no heat total was ever
   derived from them.

3. READS ARE RECORDED ONLY FOR PATHS IN THE CURRENT REPLAY, so a session
   that read a file it then deleted shows a change with no read. That is by
   design, and the run card says so in its footer rather than leaving it to
   read as a bug.

Privacy ruling, written into internal/webapp/reads.go before anything
serves it: a session id appears only in History responses on the op that
carries it, and as a ?session= filter INPUT. It is never enumerated — no
listing endpoint, no session column in /heat output, nothing new in
?by=device.

Also: PendingReads now dedupes on (path, session), not path alone. Two
agent sessions on one device between syncs used to collapse into one event
carrying whichever session flushed last — one session's reads silently
credited to another.

Tests: journal round-trip + Less-ignores-Session; the forge test (`sync
--note "claude-code session <someone-else's>"` leaves Session empty); a
multi-device syncer test carrying the session through convergence; spool
per-session dedup; hub round-trip, cross-device forge, query contract and
non-enumeration; db_conformance on file, sqlite AND postgres; runs.ts
grouping incl. legacy fallback; a Playwright spec on the seeded run card.
2026-08-11 04:18:53 +09:00
8fce9d2c46 perf(webapp): cache parsed journals so history stops re-downloading everything (BEA-85) (#133)
History folded every journal of a project on every request — 8-10s on a real
project, and paging made it worse rather than better, since each "load more"
re-downloaded and re-parsed the lot. There is no DB in this path, so no index
was ever going to help.

Journals only GROW: a device appends only to its own (the one-writer
invariant) and the hub's appendOp rewrites its key with strictly more bytes.
So the (Size, Modified) that List already reports proves a parse is still
current — no staleness window, no expiry, no new Backend method. A warm
request now pays one List and nothing else.

The cold path can never be helped by a cache, so the Get loop is concurrent
too (errgroup, limit 8) instead of one serial round trip per device.

Files() reads the same funnel, so folder listings get it for free.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:08:47 +09:00
e19fec0534 feat(webapp): old URLs follow a moved file, and share links follow the file (BEA-81) (#130)
There is no rename in beardrive: the scanner emits a put at the new path and
a delete at the old, same device, same blob, one cycle. Everything keyed on a
path therefore broke the moment a file moved — the viewer 404'd, history lost
the file's own past versions, restore refused them, and a share link either
404'd or silently served whatever unrelated file later took its address.

internal/webapp/moves.go derives the pairing from the ops the replay already
walks, cached with the snapshot. Deliberately not a rename op: journal.Less
and Replay are what every device converges to, and every already-shipped
journal would still need the heuristic to read its own history.

The two rules point in opposite directions on purpose. A viewer URL is an
address, so a LIVE path always wins and only an empty one redirects. A share
token is a promise about one file, so it follows the file even when a new one
takes the old address — and 404s forever once the file is deleted.

Nothing here writes an op or touches sync.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:52:20 +09:00
826d13795f fix(webapp): show how many times a public link has been opened (BEA-76) (#125)
Every /s/<token> hit was already recorded as a share-kind read, carrying
both a count and a timestamp — and then thrown away at the UI layer. The
Public links table showed only who shared a file and when, six inches
below a file header that already said "1 shared". Two personas filed it
independently on the same tour.

The number now rides the shares list:

  * ReadLedger.ShareOpens(project) aggregates share-kind buckets per path,
    all-time. Share-kind only 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 date.
  * shareJSON takes the project's opens map, built ONCE per project by the
    caller and indexed per row. Both callers — the project list and the
    org-wide audit — hoist it above their loops; a per-share call would be
    a full byKey scan per row.
  * Counts, never openers. The share actor is token+"/"+IP, a public
    credential joined to an IP, and it stays in the ledger. There is no
    distinct-openers field, deliberately.
  * Reads off means the keys are ABSENT, not zero: `0` would claim nobody
    has opened a link on a hub that never looked.

shareDetail() is the leverage — the settings table, the org-wide audit and
the file page's share banner all render through it, so one string function
covers three surfaces. Once the row carried the receipt it truncated to
"3 op…", so the detail cell wraps instead of ellipsizing; the path keeps
its ellipsis, since it is a link with a tooltip and the column that can be
arbitrarily long.

Counted per FILE, not per link: heat is keyed by path, so two tokens on one
file report the same number. Worded that way in the section copy, alongside
the other honesty — opens are debounced visits, not requests.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:38:44 +09:00
Snow Lee (Sungwon)andGitHub 119d4abf79 feat(webapp): render .csv/.tsv as a table instead of a wall of monospace (BEA-74) (#124)
A .csv already previewed — as raw text in a <pre>, columns lining up only
if the file happened to be padded. It now renders as an HTML table with the
first row as a header.

The parser is a new pure lib/csv.ts (~50 lines of RFC 4180: quoted
delimiters, "" as a literal quote, newlines inside quotes), so no
papaparse. It never throws: null means "not a table" — an unterminated
quote, or a file with no delimiter at all — and the caller falls back to
the very <pre> it renders today. That fallback is structural rather than a
second code path, because TextView gained a `delim` prop instead of a new
component: it also keeps the ["text", fileURL] query key a restore
invalidates and the retry:false a pinned ?v= version needs.

.tsv is new here — it used to fall through to SniffView and render as
text. The delimiter comes from the extension, never from sniffing.

Big files are capped at 5,000 rows with the count stated on screen
(virtualization is out of scope). Wide files scroll inside .csvbox, whose
rules are scoped under that class on purpose: the file pane carries
.markdown, and the plain .markdown table rules — including the ≤900px one
that turns a table into its own scroller — would otherwise out-specify a
bare .csvview and give the page two nested scrollers.

Not doing: sorting, filtering, search, editing, XLSX.
2026-08-11 00:22:18 +09:00
Snow Lee (Sungwon)andGitHub 27cc558ac7 fix(webapp): new-project dialog preselects the RECOMMENDED row (BEA-72) (#123)
The RECOMMENDED badge renders on options[0]; the initial selection was
hard-wired to "" (the last row, Empty project), so on any hub shipping
templates the dialog contradicted its own advice — and the default was the
option that produces a project with nothing to look at.

Hoist the options array above the state and seed `template` from
options[0].value: badge row and checked row are now the same element by
construction, not by coincidence. On a template-less hub options[0] is
"I already have a folder", which still creates an empty project.

The e2e test that pinned the old default now asserts the invariant (badged
row === checked row) rather than a title, and checks that creating without
touching the radios actually seeds the template.
2026-08-05 17:25:53 +09:00
Snow Lee (Sungwon)andGitHub 83613f4c85 fix(webapp): /history?path=<file> shows that file's history instead of the whole project (BEA-64) (#118)
* fix(webapp): /history?path=<file> shows that file's history instead of the whole project (BEA-64)

The History API takes ?path=/?prefix=, so a reader who has seen the API
types the query form at the page too. The router dropped it: viewTarget
stayed empty, the project-wide feed rendered, and nothing said a parameter
had been ignored — read as "this file has no history" or "history is
broken".

Honour it and normalize, the same shape as the legacyView and trailingSlash
redirects already there: parseRoute sets viewTarget from ?path=/?prefix=
(only on the history view, only when no path segment named a target) and
flags the URL for replacement; HubApp swaps in the canonical
/history/<target> URL with the filters intact.

The legacyView redirect next door dropped history filters on its own hop —
one-argument fix in the same call shape.

* docs(architecture): Route.queryTarget joins the normalization flags (BEA-64)
2026-08-05 11:04:49 +09:00
5330532f7f fix(webapp): landing on / opens the project you last used (BEA-75) (#115)
Hitting the hub with no project in the URL always resolved to projects[0]
— alphabetically first, unrelated to what you were doing — and HubApp then
rewrote the address bar to it, so the wrong choice was the one that got
bookmarked.

The browser now remembers the project it last opened and prefers it. The
precedence chain gains one clause between the just-joined org and the
fallback; a remembered id is looked up in the project list, so one that was
deleted, or that this account can no longer see, simply doesn't match and
projects[0] takes over with nothing on screen to say so.

localStorage, per browser and origin-scoped, so two hubs never share an
answer. Both helpers swallow — storage throws in Safari private mode, and a
preference is never worth a broken page.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:16:54 +09:00
e02bd5330d feat(cloud): launch pricing guardrails — egress caps, ignore defaults, storage tiering (#114)
QuotaProvider grows a read half: CheckRead(org, bytes) and RecordEgress(org, bytes). CheckRead is enforced on /s/* only — a public share link is the sole unauthenticated door to stored bytes, so it is the only egress a plan can cap. The sync proxy and viewer merely RecordEgress: refusing a device mid-cycle surfaces as ErrForbidden, which the syncer reads as "access is gone — pause and touch nothing", and sync must never break over a bill. UnlimitedQuota stays the OSS default. countingWriter bills what actually reached the client rather than a size claimed before the write.

bdrive init warns past 1 GiB or 20k files and says how to narrow scope; syncer.Measure sizes that through the real Filter and the one walkFolder predicate. starterIgnore gains video/archive/disk-image globs and Library/ — every version is kept forever, so a big binary committed once is paid for forever on every device.

deploy: a Nearline-at-30-days lifecycle rule and the arithmetic for why it stops there. Coldline and Archive only pay off below roughly one read per month, and a first sync pulls every historical blob rather than just the current tree, so blob read rate tracks device onboarding.

docs/launch-plan.md said Cloud was waitlist-only and framed Product Hunt as an OSS launch whose goal was not signups or revenue; both are stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:16:42 +09:00
77a68542ea feat(security): 318 hardening fixes across hub, sync, CLI and SPA (#112)
* fix(security): close 12 authorization holes found by adversarial round 1

Four offensive agents attacked the hub's trust boundaries in isolated
worktrees; every finding is a Go test that failed on the tree before the
fix and passes after. 43 TestSec_* regression tests land with the fixes.

The two that matter most:

- A grant outlived org membership: projectPerm consulted p.Perms before
  checking the org role, so removing someone from the org through the API
  left their explicit project grant working. Offboarding did not offboard.
- Any device could PUT any other device's journal key — the hub never
  compared the key to X-Bdrive-Device. That is the "each device writes
  only its own journal" invariant, enforced nowhere.

Also: uploads accepted .bdrive/ and .git/ paths (and materialize applied
only filter.Skip, never neverSync, so a hostile peer journal could too);
the org share audit handed every member public /s/ URLs for projects they
were denied; password reset left old sessions and device tokens valid;
blobs were not verified against their content address; quota was bypassed
by chunked encoding and by a client-declared size; X-Forwarded-For
defeated both rate limiters, login brute-force included; the /s/* sandbox
CSP was missing on error paths; expired shares were revocable by anyone;
and projectPerm failed open to admin for org-less and unknown projects.

Known open, recorded in .claude/security-goal.md: the device header is
self-asserted (the fix buys an audit trail, not identity), and the
Dir==nil/Auth==nil admin escape needs a design decision first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(security): close 17 more holes found by adversarial round 2

Round 2 attacked the five boundaries round 1 never reached (read heat,
path handling, secret leakage, the agent hook guard, the metadata store)
plus the places round 1's coverage was overstated. 43 more regression
tests; 86 TestSec_* now green.

The critical one was not on the original board. A journal op's Blob field
is a raw storage key that nothing validates: handleStorePut checks blob
keys and their content hash, but a journal is arbitrary JSONL that never
passes through that validator. One PUT of your own journal with
"blob":"../../<other-project>/blobs/<sha>" — or "../../../etc/passwd" —
then reads back through the ordinary /file, /download and /render routes.
Any member read any file on the hub host, across orgs and outside the
storage root. Guarded where content resolves, and localBackend now
refuses a key that escapes root at all.

Also: /blob served HTML and SVG inline on the hub origin with no sandbox
CSP (stored XSS via History); bdrive init deleted the hooks it had just
written whenever $HOME is a git repo, silently disabling sync hooks
machine-wide; any account could rewrite another org's device registry row
and forge History attribution; a planted device id became a heat "reader",
putting an identity in an API response that must never carry one; storage
errors relayed the hub's absolute paths (and on S3 the bucket and key);
/auth/login?next= was an open redirect via backslash and TAB; the org and
project registries handed out their live maps (self-promotion to owner,
plus a hub-killing concurrent map iteration); revoked invites came back
after a restart and refused writes applied in memory anyway; a share
minted by someone since removed from the org kept serving publicly;
a newline in a folder name made the hook guard spawn bdrive outside any
mount; single-volume upload escaped through a symlink; the seat check was
check-then-act; and the hub data dir holding auth.json ended up 0755.

Known open and recorded in .claude/security-goal.md: nothing expires (no
TTL on tokens or sessions), the Dir==nil/Auth==nil admin escape, and
client-asserted Op.User. Postgres was never exercised — row 14's
SQL-injection result covers file and sqlite only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(security): close 17 more holes found by adversarial round 3

Round 3 attacked the journal fields nobody had audited, the fixes rounds
1 and 2 had just landed, the real `bdrive serve -c` config path, and the
read ledger on a live Postgres. 133 TestSec_* now green.

The two worst are in the syncer, and both are arbitrary file write on
every teammate's machine. materialize guarded Path with neverSync, which
splits on "/" and looks segments up in ReservedDirs — ".." is not a
member and no ignore rule mentions it, so one JSONL line pushed to your
own journal key reached ~/.ssh/authorized_keys on every device that
synced. And the reserved-directory guard was case-sensitive, so
.GIT/hooks/pre-commit sailed past it and APFS resolved it into the real
.git/hooks. Op.Mode was applied verbatim, setuid bit included.

Attacking our own round-2 fixes paid for the slot twice over: ownsDevice
turned out to be a one-request speed bump (the refused report is what
registered the forged id, so the second identical request passed) and
its first-caller-wins rule made device registration a claim-staking
primitive an outsider could use to forge History attribution and
silently kill the real owner's read heat forever. Both dissolve by
keying the registry on (account, id) instead of treating the client's
device header as a hub-wide namespace. trust_proxy, added in round 2,
took the first X-Forwarded-For hop — but XFF grows left-to-right, so
turning it on disabled the login brute-force limiter instead of fixing
it.

Also: a peer's Lamport: MaxInt64 wrapped a victim's clock and silently
reverted its own edits on its own disk; History leaked other orgs'
device names and was a hub-wide device-existence oracle; anonymous
/api/config named the storage bucket; /auth/reset enumerated accounts by
timing and was not rate limited; the signed-in hub UI was frameable and
sniffable; an asset miss returned the app shell marked immutable for a
year; Op.Size forged Content-Length; a share on an org-less project
survived its creator's offboarding; a refused upload still created
directories outside the served folder; and one NUL in a read report
wedged the entire hub's read telemetry permanently on Postgres.

Verified against a real Postgres 16 this round. Known open and recorded
in .claude/security-goal.md: NUL round-trip on Postgres text columns
(refusal vs encoding is a design call), nothing expires, the
Dir==nil/Auth==nil escape, client-asserted Op.User, and store/sign on a
backend that can actually presign — never reached in three rounds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(security): close 32 more holes found by adversarial round 4

Round 4 opened five packages no previous round had touched — store,
journal, config, remote and cmd/bdrive — and attacked the fixes rounds
1-3 had landed. It broke five of them. 166 TestSec_* now green.

The four criticals:

- ownJournal bound the journal key to the X-Bdrive-Device header of the
  same request, and nothing bound that header to an account. Round 1's
  test varied the key while holding the header fixed — the one
  combination that was refused. Move both together and any member wrote
  and replaced any peer's journal: her ops vanish, every device replays
  the attacker's deletes, History credits them to her.
- store.BlobPath joined Op.Blob straight onto the blob dir with no check
  that it was a sha256, and HasBlob answering true made syncer.pull skip
  hash verification — so a peer's journal op read any file on every
  teammate's machine into their working folder.
- A folder's .bdrive/config.json chose where this device's hub token was
  sent. The file travels with the folder, so a zip or a colleague's copy
  redirected the credential to any host, http:// included.
- sync --prune read .bdriveignore before the cycle and pruned against the
  version the cycle had just pulled, so it deleted for the whole team
  under exactly the ! rules it refuses to run with. A teammate running
  bdrive scope was enough; no attacker needed.

Also: two symlink escapes in materialize (unsafeRel judges spelling, not
disk); three ways one peer op killed sync permanently on every device
that pulled it, including a panic on a short blob string; the ignore-file
reload dropped the nested-mount boundary; round 3's Lamport ceiling was
inclusive and so still reachable; the (account, id) device rekey held
neither on the read path nor on disk, so a restart handed the device to
the squatter; presigned uploads bypassed the content-address guard
entirely — that whole branch had never executed under a test, since every
fixture used file:// which cannot sign; remote.Prefixed, the single
containment primitive for multi-tenancy, did not contain; and a mount id
from the untrusted folder config escaped $BDRIVE_HOME.

Plus: bdrive export wrote hub-named keys as tar members unvalidated; the
device token followed cross-origin redirects; a symlink in the file://
storage root read and wrote anywhere on the host; GCS presigned PUTs
bound no size; presigned device uploads were never billed; trust_proxy
was wrong for the third round running; one bad line voided an entire
journal; Op.Path was not byte-exact through JSON; Less was not a total
order, so Replay's determinism rested on a caller's accident; and client
journals were 0644.

Three new scoreboard rows: client local state, the project archive, and
the device as client of a hostile hub. Still zero tests after four
rounds: internal/daemon and internal/autostart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(security): close 29 more holes found by adversarial round 5

Round 5 opened internal/daemon and internal/autostart (zero tests after
four rounds), drove the browser presign flow, and attacked round 4's
fixes. It broke seven of them, including the one round 4's commit message
called "the critical". 247 TestSec_* now green.

ownJournal failed four separate ways. The write doors called
observeDevice BEFORE ownJournal, so for an unclaimed id the request
manufactured the fact that authorized it — and device ids are not secret,
History publishes them. The claim was then permanent and unrecoverable:
no Delete on DeviceRepo, no release, no admin route, no CLI re-mint, and
a 403 naming no remedy, so any member could deny any colleague's laptop
the ability to sync forever. Rows were global but visibility per project,
so offboarding a teammate released her journal to whoever was left.
And because an ownerless row ranked as the earliest claim, every hub
upgraded from before rows had owners had the binding switched off for
precisely its established devices. Ownership is now hub-wide, an
ownerless row authorizes nobody, observation happens after the decision,
and project admin is the documented recovery path.

The other critical is a divergence primitive: pull resumed at an op
COUNT, and round 4 had just taught Parse to skip bad lines silently. One
undecodable line inserted among lines a device already counted shifts
every appended op down by one, so two devices replaying one journal hold
different states permanently — and the peer picks the split. Now resumed
at a byte offset.

Also: a mid-run edit to .bdrive/config.json moved a whole project to a
remote of the writer's choosing, with no restart and no credential, and
the daemon then pulled from it; verify-on-read was defeated by uploading
honest bytes first, since a presigned URL is replayable for its TTL;
appendOp's lamport wrapped int64 and silently broke last-writer-wins for
every later upload in the project; peer journal strings reached bdrive
log's terminal unescaped, so the audited party could rewrite the audit
(OSC 52 to the clipboard, \r to repaint a delete as a put); path_raw let
one journal line name two different files to two reader versions; Stop
signalled whatever pid a 0644 file named; locked() failed open, so status
lied and stop stopped nothing; and a macOS path containing "&" made the
autostart plist unparseable while Install reported success.

Two four-round deferrals are now answered rather than carried.
Dir==nil/Auth==nil is not reachable — nine real configurations, both
arms, real project ids — and is a guarded invariant. The Postgres NUL
question was swept across seven stored-record surfaces on a live
Postgres: no silent-loss path, and cleanUploadPath now refuses control
characters so it is unreachable through the API.

Quota became a reservation with reconciliation: reserved at the grant so
concurrent grants cannot oversubscribe, charged on arrival, released free
on expiry, and charged once.

Recorded, not hidden: permHub builds a hub with Devices == nil, so round
4's ownership binding was inert in that fixture and earlier "clean"
results measured through it proved less than they looked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(security): close 30 more holes found by adversarial round 6

Round 6 attacked round 5's fixes, the completeness floor (templates, CLI
commands, the untested exported API), and — new this round — the test
suite itself. 290 TestSec_* now green.

One hacker reverted 33 of the accumulated fixes one at a time to see
which the suite actually caught. 28 held. Five did not, and those five
are the most valuable result of the round: permHub built its hub with
Devices == nil, so a dozen journal-pushing tests had been proving
permission and never ownership; the ownerless-legacy-row test passed
because its helper never set Op.Device, so the r5 hole it guards would
have gone undetected; nothing asserted that an outstanding presigned
grant counts against the quota cap, which is half of reserve.go's
contract; row 6 claimed cleanUploadPath refuses control characters and
named no test, and that guard is what keeps the Postgres NUL divergence
unreachable; and unsafeRel — round 3's headline client fix — could be
deleted with the suite green, surviving on round 4's UnderRoot. It also
accepted ".", contained only because hashFile happens to fail on a
directory first.

The criticals: round 5's byte-offset pull resume was the same divergence
primitive it replaced, twice. A peer that publishes in two stages and
cuts the first mid-line makes one chosen device permanently skip the op
that straddles the cut while every other device applies it. And round 5
deleted the shrink guard, so a peer withdraws an op every device already
applied — the file vanishes from teammates' folders with no delete op,
nothing in the journal, nothing in History. Separately, Deny removed an
account but every authorization decision downstream keys on email, so
grants and org roles stayed attached to the address: re-registering it
walked back in as project admin, and its public share links kept
serving.

Also: reset and verification mail took its link host from the request, so
an unauthenticated stranger could have the hub mail a victim a genuine
reset link pointing at the attacker's server; a refused password reset
reported "Password updated"; share revocation, approval, policy and
account removal all took effect in memory after the store refused them,
each failing in the widening direction; one journal push with a year-2300
timestamp overflowed the History cursor and hid the whole audit feed past
page one; account ids were 32 bits with no uniqueness check, and the
birthday bound is ~9,300 accounts for a 1% chance of silently
transferring one account's credentials onto another; the new reservation
ledger had a data race on the billing path, a check-then-act that let 5
of 16 concurrent grants through a cap fitting one, and released arrived
bytes unbilled on expiry; safeField stripped C0 but not the 8-bit C1
controls that are CSI/OSC/DCS/NEL in any xterm-lineage terminal, nor
bidi overrides; and internal/templates — first contact — bypassed
cleanUploadPath entirely and wrote through symlinks using the shipped
template with no hostile input at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(security): close 29 more holes found by adversarial round 7

Round 7 drove `bdrive init` end to end for the first time — named the
largest gap by two consecutive CISOs — and tripled the sabotage sweep to
53 reversions. 326 TestSec_* functions green.

init held two criticals the moment it was actually run. `init --server`
took the no-auth branch when a server answered {"auth":{"enabled":false}}
and rewrote settings.Server without touching settings.Token — and
settings.Server is the entirety of round 4's token binding, so a 30-line
HTTP server collects the real hub's bearer token. And `init $BDRIVE_HOME`
was accepted, because the .bdrive reserved-directory rule only applies to
segments below the mount root: from there settings.json is an ordinary
top-level file, so the first cycle pushed this device's token to the hub
as project content, for every member and every teammate's disk.

Two more criticals were old fixes on doors they never covered. Round 4
bound the device token to settings.Server's origin in remote.deviceToken
— the sync backend's door — while share.go reads its destination from the
folder's .bdrive/config.json and hands the token straight to it, and the
CLI's own http.Client had no CheckRedirect at all. And round 6's offboard
only log.Printf'd RemoveMember's "cannot remove the last owner", so
anyone signing up on a removed sole-owner's address inherited org
ownership and admin on every project in it.

The sabotage sweep found 8 more guards deletable with the whole suite
green, including MayActAs (every existing test planted an id that
validDeviceID rejects first, so the ownership loop was never consulted),
both framing headers (round 3's test held the disjunction, not the code),
and sqlAccountRepo's id guard — where the untested backend is the one
managed and Postgres deployments run. For the first time the sweep also
covered the three choke points themselves: reverting requirePerm turns 30
tests red, projectPerm 21, authGate 9.

Also: two more journal-undo primitives past round 6's count guard, now
keyed on identity; reset-mail poisoning survived because the pin was
first-request-wins and round 6's own reproducer sent the honest request
first; the /store/* journal door accepted paths /upload/commit refuses,
so the three spellings of one path rule became one exported predicate;
bdrive forget injected .bdriveignore rules outside any managed block;
bdrive resume built a volume path from an unvalidated registry key; round
5's $HOME-is-a-git-repo fix broke again on a string compare of two
spellings of one path, silently disabling every agent hook on the machine
while init reported success; and the CheckWrite call round 6 moved under
the hub-wide ledger mutex stalled every project's sync cycle.

Known open: a peer can still un-publish an applied op by corrupting its
line and appending as many as it removed — the clean close is hub-side
append-only enforcement on /store/*, a behaviour change no failing test
demands yet. The init tests all ran with auth.enabled false, so the login
flow inside init — where the first critical lives — is still untested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(security): close 26 more holes found by adversarial round 8

Round 8 drove the authenticated login flow, the last five CLI commands,
and sabotaged row 15 exhaustively. 621 TestSec assertions green.

Three of round 7's fixes were half-fixes. Its $BDRIVE_HOME guard closed
the child direction only, so `bdrive init` on any ANCESTOR of the home
still pushed this device's bearer token to the hub as project content —
and a relative $BDRIVE_HOME disabled the guard entirely, reopening the
original critical. Its mail fix still seeded the pin from r.Host, so on a
fresh process an anonymous stranger could have the hub mail a VICTIM a
valid reset link on the attacker's server; the mirror image turned
password recovery off hub-wide with one request. And the hole round 7
declined to close got a reproducer that also invalidated its proposed
remedy: a peer publishes its last op unterminated, the next append fuses
onto that line and stops it decoding, so an op every teammate has on disk
vanishes with no delete op — through a byte-level PURE APPEND, which
hub-side append-only cannot stop.

Two more criticals were first contact. The loopback login callback had no
proof of possession: its only binding is a `state` that is printed to
stdout and passed to xdg-open as argv[1], so any local process that can
run `ps` signs the device in as its own account and the user's folders
sync into the attacker's project. And the three /store/* READ doors call
observeDevice as their first statement — round 5 moved it after the
decision on the write door and never touched the read doors — so one GET
with a victim's device id first-claims it hub-wide and locks that device
out of its own journal, from read permission on any single project.

The sabotage sweep is the round's most important result. 48 guards in row
15 reverted one at a time: only 20 were caught. A 57% false-negative
rate, nearly 4x rounds 6 and 7. The materialize DELETE loop's three
guards — one of which ends in os.Remove — were held up by nothing,
masked because scan's delete pass applies the same rule to the same cache
first, so no whole-Cycle fixture can tell which guard refused.
absorbLamport's ceiling and tickLamport's stop mask each other, so the
existing test passes with either removed. Eight of the 26 misses now have
tests; 18 remain open, and row 17 was never reached at all.

Also: a hostile export archive chose which of your existing projects it
landed in (create-or-join-by-name, emptiness checked after the join); a
folder that merely arrived on disk stole an enrolled mount's registry
row, so at next login the real project's daemon ran on the arriving
folder; one device approval minted N tokens and bound them to a device
the human never approved; logout left the credential live with no
revocation route; and two unbounded reads on the device side had the
declared size in scope at the call site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(security): close 5 more holes found by adversarial round 9

Round 9 spent most of its budget on sabotage rather than new attacks, and
the numbers are the point. 705 TestSec assertions green.

Row 17 swept in full: 44 reversions, 9 missed (20.5%). Rows 13 and 18-22:
57 reversions, 12 missed (21% overall — row 13 at 40%, row 20 at 33%,
row 18 at 0%, the only perfect row swept in any round). And row 15's 18
remaining untested guards got tests, each verified red under its own
reversion — including both of round 8's flagged leads, which turned out
to be correct guards that were merely untested. That agent reported dry.
So roughly 29 previously-deletable guards are now genuinely pinned.

All five live holes are regressions in round 8's own fixes. Its
re-assertion fix re-published a withdrawn op at its original low lamport,
which made it a losing local unpushed op the instant it was written — so
conflictCopies did what it exists to do and the victim created, signed
and pushed a file holding content the peer chose, at a path that never
existed. The same admission rule had no guard for withdrawn deletes and
consulted neither the ignore filter nor neverSync, so a device
republished paths it deliberately refuses to materialize. Re-assertion
now requires that this folder's own cache stands behind the op.

Its sizeBound fix returned on a sha mismatch instead of skipping, so one
understated Op.Size in one line of a peer's journal permanently withheld
every blob queued behind it — before round 8 the read was unbounded, the
sha matched, and the files arrived. The bound was not the defect and is
unchanged; the error is now remembered and returned after the batch.

Also: the org heir was chosen by the smallest email address rather than
the longest-standing member, so the newest member inherited ownership and
project-admin on every project when a hub admin removed a departed
employee; $BDRIVE_HOME was created 0755 by LoadDevice, which runs before
almost everything, so listing alone named every project, every device in
the fleet and every content hash without opening one of the 0600 files;
and round 8's ResolveMount condition stranded a genuinely moved project
behind a leftover config, with init itself blocked on the same check.
Move-vs-copy is now decided by dev+ino, which a rename preserves and a
copy cannot reproduce.

Judgement call recorded: the nested-mount carry reverts green but stays.
Deleting a defence-in-depth guard because the tests did not notice is
exactly the reasoning the sabotage table exists to distrust. It is marked
as not counting toward coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(security): close 30 more holes found by adversarial round 10

Round 10 drove three surfaces end to end instead of sweeping, and the
contrast is the round's real result. Round 9 spent itself on sabotage and
found 5 holes; round 10 pointed a device at a hostile hub, executed the
Linux autostart code for the first time in ten rounds, and requested the
three /auth pages nobody had ever fetched — 30 holes. 777 TestSec
assertions green.

Row 19 is the clearest data point the loop has produced. A round-9 sweep
scored it 12.5% missed and annotated it "no reachable impact", so it
wrote zero tests. Driven end to end it held 11 holes, one critical: pull
skips a listed journal only on an exact string compare, so a hub listing
journal/DEVA.jsonl resolves to the same file as deva.jsonl on APFS and
NTFS and overwrites the device's OWN journal — the invariant the whole
concurrency design rests on. A sweep can only find a hole where a guard
exists; it cannot find a class nobody wrote a guard for, and every
critical since round 7 has been that kind.

Also from the hostile hub: one unusable listed key hid every peer
permanently; one listing minted 200k local journal files; the hub sized
the device's own allocation at two layers, and one boolean in a sign
response made a device publish an op for content it never sent, with the
cursor advanced past it so it never retried. putDirect shipped file
bytes to any host the hub named — round 4 dismissed this because "the hub
already holds the data", but at the moment it names the destination it
does not, which is what the upload is for.

Row 5's device binding is closed after four rounds of deferral. A
read-only member's device could never register, so any member with write
anywhere took its id permanently — and the arm that let them through read
a field the attacker writes. The id is now minted hub-side at login,
bound to the authenticated account, at all three mint points. That was
first framed as requiring the supersession of round 7's test; it did not.
Round 7 asserts a read door creates nothing, and that property is
unchanged and strictly stronger, because the read door now has nothing
left to claim with.

Four of round 9's five fixes had live residuals: re-assertion laundering
returned on any device that cannot push (conflictCopies measures unpushed
against a cursor that only advances on success, and read-only is the
documented steady state); the sizeBound fix still let one peer integer
suppress the victim's own push; earliestMember was inert on every
upgraded hub; and the dev+ino discriminator was inert on every row that
existed. ResolveMount turned out to be a write with a read-shaped name,
so bdrive restore and forget enrolled the device in projects it was never
init'ed into.

Recorded as a measurement gap, not a finding: row 14 was scored clean on
every backend for seven rounds, but this is the first round ever run with
a Postgres DSN, and metaBackends silently omits the arm without one. A
skipped arm and a missing guard are indistinguishable in a green suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(security): close 24 more holes found by adversarial round 11

Two criticals, both first-contact findings on surfaces ten rounds had not
reached.

An `.xml` file got script on the hub's own origin with the reader's session:
`sandboxInline` walled off a LIST of content types where the thing it protects
is a PROPERTY ("the browser parses this as a document"), and the whole XML
family sat outside the list while having the property — an XML document carries
its own `<?xml-stylesheet type="text/xsl"?>` and the XSLT output is HTML in the
origin that served it. `inlineMarkup` is now the property, `inlineType` serves
the XML family inline as text/plain so nothing parses it as a document, and
`nosniff` goes on every stored-bytes door.

A plain member replaced another account's journal by spelling her device id in
a different case: every hub ownership decision was a byte compare while APFS
and NTFS fold, so one login and one PUT broke the one-writer invariant the
whole concurrency design rests on. `canonDeviceID` folds at the trust boundary,
the registry folds on load and at every entry point, and `ownJournal` requires
the canonical journal key.

Also: org ownership was drawn by Go map iteration (`sort.Slice` on an all-zero
`Created` column) and now needs real evidence of age or produces no heir; a
revoked grant was restored by any unrelated write from a second hub process
(grant writes are row-scoped now, on all three backends); `/history` named
whoever the pushing device typed; the admin recovery arm locked the real owner
out of `bdrive login` forever, across the org wall; `bdrive scope` was the
unescaped door `forget` used to be and could wipe the team's synced rules;
`journal.SafePath` let every bidi control and every C1 through.

Two tests were touched, both disclosed in .claude/security-goal.md:
TestSec_DB_NULBytesDoNotTruncateRecords is retired (its assertion is one
Postgres cannot implement), and TestSec_Scope_AddCannotCreateADirectoryOutside-
TheProject is rewritten against the guard it was meant to test — it called
os.MkdirAll itself, so no production code sat between its setup and its
assertion. The rewrite was verified to go red with the guard removed.

918 TestSec assertions green, whole suite green including Postgres and -race,
108/108 Playwright, Linux container clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(security): the run-mode note only printed when the suite was already red

Round 10 moved the "postgres UNTESTED in this run" note from t.Log to
os.Stderr because t.Log is invisible without -v. That was not enough: `go
test` buffers a package's output and discards it on success without -v,
stderr included. So the note that exists to make a silent coverage gap loud
was itself audible only during a failure — the same shape as the hole it
guards against, and the reason it went unnoticed is that every round that
read it had a red suite in front of it.

secrunNotify now also writes to /dev/tty, which survives that buffering.
Verified under a pty on a fully passing run with no -v.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(security): close 24 more holes found by adversarial round 12

The critical one is the fifth instance of "something survives offboarding":
an org invite outlived the membership, the ownership AND the account that
minted it. OrgDB.Redeem/ValidInvite now resolve the minter's ownership at
read time — the rule shareCreatorStillBelongs already applied to a share
link — and retire the invite when it fails, so EvictMember's heir promotion
cannot revive it.

Round 11's row-scoped write landed on ProjectRepo only. OrgRepo, ShareRepo
and DeviceRepo had the same whole-record shape, so a second hub process's
unrelated write resurrected a revoked org membership, a revoked /s/ link,
or erased a device binding. And the authorization READ path never got it at
all: ProjectDB answered from a copy taken at boot, so a revocation took
effect on one process and no other.

Also: a push could credit another account through Op.Author; journalOps
checked the Note and not Author/UserName; a display name skipped trimText;
a password reset left outstanding reset and verification mail grants alive;
nosniff missed two stored-bytes doors; SafeText admitted the zero-width
formats; a project name could break out of the ConnectGuide paste prompt;
inviteTokenFromNext matched "/join/" anywhere in `next`; file content chose
what read-log reported as a read, and the hub recorded reads for paths that
do not exist.

Frontend: decodePath threw URIError on a link in a teammate's document and
unmounted the whole SPA persistently (fixed at the decode, plus a real
ErrorBoundary), and the router kept the Object.prototype lookup bug round 11
fixed in ProjectIcon.

Decision, not a patch: agent HOOK config (.claude/settings.json and
friends) is now reserved in both directions. Skills, commands and CLAUDE.md
deliberately are not — sharing what an agent reads is the product. The trust
boundary that follows from that is now written down in INSTALL_FOR_AGENTS.md,
the docs' Start-here path, and README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(security): close 17 more holes found by adversarial round 13

Round 12 gave ProjectDB a read-path refresh() because a revocation only took
effect on the process that served it. Four sibling registries with the
identical defect went unexamined for a round.

The refresh family (one fix, three service structs + two file repos):
- OrgDB.refresh — the wall IN FRONT of project permissions. A removed org
  member kept reading every project in the org; a revoked invite still
  redeemed and, on the default invite-only posture, bootstrapped the account.
  At the top of the MUTATORS too: the last-owner guard is a cross-process
  TOCTOU the write-side re-read cannot close.
- BuiltinAuth.refresh — the CREDENTIAL, not a grant on top of one. A revoked
  device token still authenticated; a deleted account signed in again with its
  old password.
- ShareDB.refresh — a revoked /s/<token> was still served to anonymous
  strangers. fileShareRepo.reload's own r12 comment named this row.
- fileAccountRepo.reload, fileReadRepo.reload — the last two file repos with
  no write-side re-read.

It lives in the service structs, not db_file.go: the staleness reproduces on
sqlite and Postgres too, and a file-only fix would have left the
two-replicas-one-database deployment fully broken.

Also:
- offboard now releases the device binding (DeviceRepo.Delete +
  DeviceRegistry.Release). A deleted account kept a hub-wide claim on its
  device id, which silently and permanently locked out the next hire.
- SafeText and trimText refuse unicode.Cf and the tag block AS A CLASS. The
  tag block encodes all printable ASCII with no glyph, so a project name
  rendering as "wiki" smuggled a shell command into the agent paste prompt.
- .mcp.json is reserved; the agent-config list is now derived from what each
  platform LOADS, not from what BearDrive writes.
- pageDevice and X-Bdrive-Device-Name go through trimText: an unauthenticated
  stranger chose the text AND the length of the hub's only consent surface.
- SetPolicy runs the startup validator, so POST /api/admin/policy cannot reach
  a posture the binary refuses to boot in.
- Insights uses Object.create(null): a folder named __proto__ erased an agent
  device from the Dashboard.

go build / go vet / go test ./... clean with and without BDRIVE_TEST_POSTGRES;
-race clean; Playwright 127/127. Linux container run not completed — see
.claude/security-goal.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(security): close the agent-onboarding holes found by round 13's fourth hacker

Four findings, all against the already-hardened tree. Its fifth (.mcp.json)
was already closed and its test passes — an independent confirmation of that
fix from a second angle.

1. A peer's `!.env` in the shared .bdriveignore uploaded another member's local
   .env on their next cycle. Round 4 made .bdriveignore team-wide on purpose
   and made `sync --prune` refuse on `!` rules for exactly that reason — but
   that reasoning covered DELETION, and nobody asked what a pulled negation
   does to SCAN. The runbook's own `bdrive init . --only docs,notes` is what
   creates the exposure: the whole repo goes under the mount with only this
   synced, teammate-writable file holding the rest back.

   Fixed asymmetrically at the upload door (Filter.SkipUp, consulted by
   walkFolder): pulled rules that NARROW apply immediately in both directions;
   pulled rules that WIDEN apply to materialize but not to scan, until this
   device authors the rules itself (init --only, bdrive scope, an editor). A
   joining device has authored nothing, so team-wide scope still works on day
   one — which a blanket "ignore pulled negations" would have broken.
   `bdrive scope --explain` reads the same floor so it cannot drift.

2. A FAILED `init --server <url>` signed the device out of its real hub and
   left it defaulting to the new one — after a run that ended in "Error:".
   ensureLogin now returns a rollback; initCmd commits the session only once
   the hub has answered with a project this device can open.

3. `init --server http://…` minted and stored a device token with no plaintext
   warning while `bdrive login` on the same URL warned — and step 2 of the
   runbook is titled "Do not run a login command". The warning moved from
   loginCmd's RunE into the shared runLogin: one sign-in door, one warning.

4. The hub chose the device-login link and the CLI printed it verbatim under
   its own "open this link in any browser". sameOriginLink falls back to the
   hub's own /auth/device when scheme+host differ.

Also: safeField gets the same unicode.Cf + tag-block class rule SafeText and
trimText got — third door, same class. Scoping guide documents the widening
rule. Two findings that are not tests (the runbook URL pinned to a mutable
branch; nothing authenticates the hub during device sign-in) are recorded in
known-open.

go build / go vet / go test ./... clean with and without BDRIVE_TEST_POSTGRES;
-race clean on webapp, syncer, store, cmd/bdrive; Playwright 127/127. Linux
container run still not completed — the Docker daemon on this machine will not
create containers at all; see .claude/security-goal.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(security): close 22 more holes found by adversarial round 14

The last round of the loop. Four hackers landed; 21 of their tests go green
here, the 22nd names a hole that IS fixed but cannot pass as written (see
below). `.claude/security-goal.md` gains a handover section for a human in
place of the "next round's targets" framing.

Sync / scope (internal/syncer)
- A peer DELETING the shared `.bdriveignore` walked straight past round 13's
  upload floor: the `IgnorePulled` bookkeeping sat behind `if want, ok :=
  target[IgnoreFile]; ok`, so a delete never updated it while materialize
  unlinked the local copy anyway — and the next cycle read the absent file as
  locally authored, dropped the floor AND the live rules, and pushed the whole
  repo. Recorded as pulled now.
- Both SyncState fields are `omitempty`, so on its first post-upgrade cycle
  every existing device adopted whatever was on disk as its own — including a
  peer's `!.env` that landed one cycle earlier (scan runs before pull, so it
  always does). An upgraded device now seeds its floor with `vouchedFloor`:
  keep a `!` line when the path it re-includes is already in this mount's
  materialization cache, drop it otherwise. That is what keeps a `bdrive
  scope` block (`/*` plus nothing but negations) from silently ending uploads
  — pinned by TestUpgradedScopedDeviceKeepsUploading.

Second-process staleness (internal/webapp)
- ProjectDB had refresh() on Get and List only. `put`→`PutMeta` is an
  unconditional upsert, so a second process's ordinary rename put a DELETED
  project back carrying the org the public-link rule reads; the last-ADMIN
  guards counted admins out of the boot-time map; GetOrCreate answered
  create-or-join differently per replica. refresh() now runs at the top of all
  ten mutators.
- DeviceRegistry had no refresh() at all — round 13 cleared it on the
  bind-away direction alone. Offboarding released a device claim on one
  process and no other: the next hire is locked out, and a re-created address
  inherits the departed account's journal write gate elsewhere. Pinned on
  file, sqlite and postgres.

Audit trail
- `/store/object` was a plain object PUT with no relation to what is stored,
  so any member could rewind their own journal — or, after inheriting a
  reassigned device id, a departed member's — out of History. Journal pushes
  now must keep every op Seq the hub already holds.
- seedTemplate journaled the hub's own template files under the account that
  ran `bdrive init --template`, byte-identical in shape to a hand upload. They
  now carry no account and a "seeded from the <name> template" note.

Text and rendering
- journal.SafeText refused every category-Cf rune and missed U+2028/U+2029
  (Zl/Zp), which the webapp's own trimText has deleted by number since round
  12. A folder row for `line<U+2028>sep.md` paints to exactly the same glyph
  run as `line sep.md`.
- A strong-RTL LETTER needs no format character to reorder a rendered row.
  Measured in Chromium, `unicode-bidi: isolate`, `plaintext` and `<bdi>` all
  leave it intact; `isolate-override` fixes it, and peer-written-name
  selectors now carry it (SPA + the auth pages' device-approval rows).
- `)` closes the paste prompt's clause exactly as `"` did; project names now
  drop both parens (org/device/account names are unaffected). PATCH
  /api/projects/{id} called trimText where create called trimName, so rename
  stored `/` and `\` — one rule for both doors now.

CLI
- `p.Template` was the one hub-chosen field in `bdrive init`'s output that
  never reached safeField.
- `--template` reported the hub's own string as proof and never looked at what
  arrived; it now always falls through to the idempotent seedLocally.

Docs
- INSTALL_FOR_AGENTS.md no longer raises the hub-seeded AGENTS.md to the
  user's authority, and its trust boundary names the hub as an author of
  folder content. Documentation defects, not demonstrated exploits: three live
  headless runs did not flip behaviour.

Known red, deliberately not worked around:
- TestSec_ProjectName_RenameBypassesTheCreateNameRule — the hole is fixed and
  separately verified; the test's own control creates a project with the
  normalized name in the same org before renaming into it, so correct
  behaviour collides with the unique-name-per-org rule.
- Two e2e/sec14fe specs upload a U+2028 path to demonstrate a rendering
  collision that this commit's ingest fix now refuses at the door.

go build / go vet clean. go test ./... green with and without
BDRIVE_TEST_POSTGRES and under -race, except the test named above. Playwright
serial: 131 passed, 2 failed (the two named above).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* test(security): the rename test collided with its own control

TestSec_ProjectName_RenameBypassesTheCreateNameRule created a project
from the same payload it then renamed a second project to. Both
normalize to "notes....etc", names are unique per org, so the rename
400'd on the collision and the test failed at its control check without
ever reaching the assertion it exists to make.

It failed that way against the FIXED code, which is the worst way for a
test to be wrong: it reads as an open hole and is really a broken
instrument. The control now uses a payload that normalizes to a
different name.

Verified load-bearing rather than merely green: reverting Update's
projectLabel call turns it red with "rename stored a path separator in
a project name: notes/../../etc".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* docs(architecture): draw the security hardening's new types and seams

The branch added shared rules and gates that the diagrams did not have
boxes for, so the pictures no longer described the code.

cli-sync: the three drifting path/text checks collapse into one
journal.SafePath/SafeText box, plus store.UnderRoot and
config.ReservedPath as their own single-rule boxes; Filter gains SkipUp
and AcceptRules and the scan/materialize rules stop being symmetric;
SyncState is drawn for the IgnoreAccepted/IgnorePulled floor; the
registry records Dev/Ino and splits ResolveMount (read, self-heal) from
EnrollMount (the only writer); the daemon's signalled pid moved inside
the flock.

webapp-server: DeviceRegistry is keyed (account, id) with
Bind/Release/OwnerOf/MayActAs; new boxes for the /store journal door,
the quota reservation ledger, sandboxInline and offboard; refresh() on
every service; the row-scoped repo interfaces, the storable validation
gate and the schema-version guard in the MetaStore block; HasBlob became
BlobSize.

webapp-frontend: ErrorBoundary, the app's floor. Also fixes escaped
quotes in a note that have been rendering this whole diagram as an error
box on GitHub since it was written.

overview: unchanged — no package appeared or disappeared and no
cross-piece flow moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* test(security): re-aim two specs their own fix made unbuildable

Round 14 refused U+2028/U+2029 at ingest. Two e2e specs proved a
*rendering* collision by uploading exactly those code points, so the fix
made their fixture impossible and left them red — a broken instrument
reading as an open hole, the same shape as the rename test.

The listing spec now asserts the ingest guard instead, and keeps the
measurement that says why the guard matters: the two names painted to
70.9844 x 16, one line box, byte-different and pixel-identical, measured
in Chromium with Range.getClientRects() over live text nodes. Relax
SafeText and it goes red before the collision returns.

The shares-audit spec is skipped with its numbers preserved. Its
reachable sibling — a strong-RTL letter, which cannot be refused without
refusing Hebrew filenames — is covered by the neighbouring spec and
fixed in style.css.

Playwright: 133 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* test(security): a comment in a spec leaked a CSS rule into the bundle

Tailwind's content scanner reads the e2e specs. A bare `isolate` token in
prose explaining the bidi fix emitted `.isolate{isolation:isolate}` into
the shipped bundle — a rule nothing uses, and a stale-assets failure for
check-dist.

Reworded to avoid the token, with a note saying why, since the next
person to explain a utility class in a comment will hit the same thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* docs(security): correct three handover entries the landing pass closed

The handover listed one Go test and two browser specs as permanently red.
All three were re-aimed after the loop stopped: the Go test was failing
against the FIXED code because its own control collided with it, and the
two specs were fixture-blocked by round 14's own ingest fix.

Suite is 1052 TestSec assertions green, 0 red; Playwright 133 passed,
1 skipped. The stopping condition itself is still not met and the notice
at the top of the file says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(sync): a Persian filename permanently wedged a device's sync

The scan door applied config.ReservedPath; the hub's ingest door applied
journal.SafePath AND config.ReservedPath. A file whose name the scan
accepted and the hub refused was blobbed and journaled locally, and
because push PUTs the whole journal object, every later push from that
device 400'd on the same op forever. Renaming did not help — the delete
op names the same path. The only surface was a line in daemon.log, and
recovery meant deleting the volume store.

walk.go's own comment already stated the rule it was breaking: "the
outbound half has to match the inbound one." It just did not name every
predicate the inbound half applies.

Two changes:

SafePath now permits ZWNJ and ZWJ, which the Cf class rule refused. Both
are orthographically required — U+200C is what makes "می‌روم" the right
word in Persian and is mandatory in several Indic scripts, and U+200D
builds most multi-person emoji. Refusing them did not harden a hub; it
told those users their filenames were illegal. The confusability they
buy is also already reachable without them: a Cyrillic homoglyph
produces the identical "two rows, one reader" tree and is allowed. So
the clause was paying a hard i18n cost for a partial mitigation of a
class that stays open. A note has no orthography, so SafeText still
refuses all four zero-widths; the two rules now share one implementation
with a flag rather than diverging.

walkFolder applies SafePath, so an unsyncable name is simply not carried
— it shows up in `bdrive scope --explain` like any other exclusion and
never enters a journal.

The regression test asserts a PROPERTY over both predicates rather than a
character list, so adding a rule to one door alone reopens it. Its first
draft was vacuous — it skipped every unsafe name instead of creating it,
so nothing hostile reached the scan and deleting the guard changed
nothing. Caught by sabotaging it; it now creates the names unix permits
and goes red when the guard is removed.

Found by a CTO review of PR #112, not by the security rounds, which
checked this asymmetry on the hub and never on the client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(hub): a proxied hub throttled all its users as one on day one

Upgrading a hub behind nginx / Caddy / Fly / Cloud Run without editing its
config made every user share ONE 10/min login bucket and capped public share
links hub-wide at 120/min: clientIP fell back to r.RemoteAddr, which is the
proxy. Correct passwords started answering "too many attempts", with no log
line saying why.

X-Forwarded-For is now trusted by PEER rather than by configuration: a proxy
that fronts a hub reaches it over loopback or a private address (sidecar,
container network, Fly/Cloud Run internal hop), so that header is the
operator's own infrastructure. A hub on a public IP still ignores it, and now
logs once instead of failing silently. trust_proxy remains the override for
the one shape the peer check cannot see — a proxy on a public address.

Which hop is taken is unchanged (last element of the last field line), and the
round 13/14 tests that pin it stay green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* perf(hub): authorization stopped re-reading the whole registry per request

Every authorized request re-read the entire project, org and account registry
under a hub-wide mutex — 14.1 ms per request at 5k projects on the file
backend, nine unfiltered SELECTs on Postgres — so the hub served roughly 60
req/s regardless of cores while ~200 devices polled /store/list every 10s.
None of this existed before the security rounds; registries loaded once at
boot.

The semantics do not change. Registries still re-read the store before every
authorization decision — that is the correctness floor rounds 12-14 built, and
a TTL would put back exactly the staleness window they closed. What changes is
the cost of asking whether there is anything to re-read:

- new optional Versioned repo capability: one os.Stat (file) or one
  primary-key lookup on a per-registry meta_version counter bumped inside
  every write transaction (SQL). A repo that cannot answer is treated as
  changed, so the fallback is the unconditional re-read that was always there.
- proj() resolved the project and then projectPerm resolved it again;
  projectPermOf takes the Project the choke point already has. handleProjectList
  and the org share audit did one resolution PER PROJECT in a loop; both now
  pass the row they are already holding.

Measured on M1, benchtime=200x, one project resolve + permission check:

  file     100 projects   331 us -> 6.0 us
  file    1000 projects  2.85 ms -> 3.9 us
  file    5000 projects 14.14 ms -> 3.9 us
  sqlite   100 projects   295 us -> 22.9 us
  sqlite  1000 projects  2.57 ms -> 22.5 us
  sqlite  5000 projects 10.86 ms -> 21.5 us

and it is now flat in project count rather than linear.

The file backend does NOT become multi-process-safe from this: every write is
still read-modify-write-rename, and the mtime+size token would miss two
processes writing the same byte count within one filesystem timestamp tick.
refresh narrows the stale-read race; it does not close it. SQL is the fix.

TestVersionGateSeesAnotherProcessWrite pins the property that matters — a
second process's create and grant change are both visible through the gate, on
file, sqlite and Postgres. BenchmarkRegistryRead / BenchmarkAuthorizedRequest
go linear again if this regresses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* perf(hub): every blob read on S3/GCS paid double egress and a full hash first

RemoteSource.verify re-read and re-hashed the whole object before returning a
second read for the actual stream, on any backend that can presign — and
PutSigner survives the Prefixed wrapper, so this was live on every S3 and GCS
hub. Every viewer open, render, download and /s/* share hit paid 2x
object-store egress and a serialized full-object hash before the user's first
byte. On file:// (the OSS default) verify is a no-op, which is why the suite
never felt it.

The check is not weakened. It is cached again, keyed on the one thing that
makes "blobs are immutable" true rather than assumed: BOTH presign doors
refuse to sign a key that already exists, so every presigned URL a blob ever
gets was minted before its first PUT and dies at mint+TTL. Once the stored
object is older than the presign TTL, no live URL for it can exist and none
will ever be minted again — the hub is the only writer left, and the hub hashes
what it relays. Only then is the verification cached. The object's age is read
after the hash, so a replay mid-check reads as seconds old and is not sealed.

Measured, 4 MiB blob, 200 reads (the "unsealed" arm IS the old code path):

  before   2.41 ms/op   2.000 storage reads per blob read
  after    0.39 ms/op   1.000 storage reads per blob read

On S3/GCS the second read is real egress and real latency, so the win is
larger there than this local stand-in shows.

NOT done: signing the content hash into the presigned URL. GCS cannot bind a
SHA-256 at all — x-goog-hash takes only crc32c and md5, and the md5 would be
declared by the same client that declares the sha, so a chosen-prefix collision
defeats it. On S3 the SDK hoists ChecksumSHA256 into the query string rather
than into SignedHeader; it is inside the signature, but whether S3 enforces a
hoisted checksum (and whether an unsigned request header would override it)
cannot be verified without a real bucket. With the seal in place the checksum
would add no security that verify is not already providing during the only
window it applies to, so it stays out rather than going in untested.
Backends that can sign but cannot bind a content address: GCS certainly, S3
pending a live check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* docs: record the three PR #112 cost fixes and what they do not fix

architecture/webapp-server.md gains Versioned/versionGate, RemoteSource's
PresignTTL + seal, remote.Object.Modified, and the new clientIP peer rule.
Both mermaid blocks parse-checked with mmdc.

.claude/security-goal.md's "known-open, deliberately deferred" list is updated
with what each fix accepts: the private-peer widening on X-Forwarded-For, the
fact that the file backend does NOT become multi-process-safe from the change
token (read-modify-write-rename is unchanged; mtime+size narrows the race and
does not close it), and why the presigned content hash was left out — GCS
cannot bind a SHA-256 at all, and S3's binding lands as a hoisted query
parameter nothing here can verify S3 enforces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(hub): the blob seal compared two clocks that need not agree

The verification cache is sound on one argument: once a stored blob is
older than the presign TTL, no live URL for it can exist, so the bytes
cannot change again and the hash need not be recomputed.

That argument is about time, and the two times came from different
machines — o.Modified is the object store's clock, time.Since is the
hub's. A hub running ahead of storage overstates the object's age and
seals it while a minted URL is still live; a replay through that URL is
then served from cache for the rest of the process's life. NTP makes it
unlikely and a container without it, or a VM resumed from suspend, makes
it reachable.

Seal after the TTL plus an hour instead. Sealing early buys nothing —
the blob is immutable either way — so the margin costs a few extra
hashes on a young blob and removes a dependency on two clocks agreeing
that nothing in the process can verify.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* test(hub): age the blob-seal fixtures instead of shrinking the TTL

The clock-skew fix in b1e9d20 is right, and it left TestBlobVerification
StopsOnceTheObjectCannotChange red: the fixture used a 1ns presign TTL as a
stand-in for "old enough to seal", which stops working the moment the margin
is an absolute allowance for clock skew rather than a multiple of the TTL —
correctly so, since an absolute allowance is what skew actually needs.

The fixtures now age what the store holds (os.Chtimes over the backing dir),
so the tests exercise a genuinely old object. That also lets the boundary the
skew fix exists for be asserted directly: a blob PAST the presign TTL but
inside the skew allowance must still be re-verified, because "past the TTL" is
measured on the storage clock and compared on the hub's. Previously nothing
covered that case.

sealAfter's comment now names what the allowance does not buy — it is a bound,
not a proof — with the single-clock alternative as the upgrade path, and the
goal file records the same residual.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* test(hub): a partial Postgres reset was a half-applied migration

metaBackends' postgres reset dropped ten tables and kept four: project_perms
and device_rows leaked rows into the next test, and — the one that bites —
schema_meta survived while projects did not. That combination is precisely
what addColumns refuses: the next open rebuilds projects WITHOUT the guarded
default_level column and then reads a recorded schema version saying it should
already be there, so the store fails to open with the rollback error.

Nothing tripped it while TestMetaStoreConformance was the only thing opening
Postgres, because it reset and opened back to back. Adding a second Postgres
consumer (TestVersionGateSeesAnotherProcessWrite) made the residue reachable.

Reset now drops every table migrate() creates, which is what reset means. And
the version-gate test no longer drops anything at all: it asserts by project
id and GetOrCreate is create-or-join, so it does not need a clean database and
must not leave a residue the other harness does not expect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* docs: a red Postgres result is only evidence when nothing else shares the DSN

The harness DROPs and recreates the schema per test against whatever
BDRIVE_TEST_POSTGRES names, so two concurrent runs produce moving failures
that read as regressions. Record how to tell the two apart, since the
scoreboard leans on Postgres results.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:20:51 +09:00
c8ab5504f7 feat(webapp): history can be narrowed by path, author and date (BEA-67) (#113)
* feat(webapp): history can be narrowed by path, author and date (BEA-67)

The project History view was a flat scroll with no controls but per-row
restore/open/download. Fine at eleven rows; unreadable after a month of
agent writes, and agents write far more than people do.

Four reader filters on GET /api/p/<id>/history — q= (case-insensitive
substring of the path), user= (exact account), since=/until= (UTC bounds,
inclusive at both ends, RFC3339 or a bare YYYY-MM-DD). They compose with
each other and with the existing path=/prefix= scoping, and they are
applied in the same walk as path/prefix — BEFORE the sort and the cursor
skip — so next_cursor keeps meaning "the next matching entry" and paging
under a filter needed no new machinery. kinds[] is still computed over
every op, so a filtered view classifies edits the same as the full feed.
A malformed since/until is a 400, not a silently unfiltered feed.

The filter bar drives those params through the URL rather than component
state: a narrowed feed is a link you can send, it survives reload, and
Back undoes it. The author list accumulates across fetches — rebuilding
it from the current feed would strand a reader who filtered by one
author with only their own name to pick from. Zero matches gets its own
empty state with a Clear.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(architecture): the frontend diagram parses again

The escaped quotes inside the NewProjectDialog note made the whole
classDiagram fail to parse — it has been rendering as an error box, not a
diagram, since that note landed. Same breakage on origin/main; noticed
while adding HistoryFilters to the same file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:11:01 +09:00
7861b755ac fix(webapp): the reads × freshness chart's busiest dot fits inside its own frame (BEA-60) (#111)
Three defects on one panel, all of them stopping it from delivering its one
insight — "these docs are hot but stale, go fix them":

- The hottest file plotted at exactly y = M.t with a radius up to 7px, so the
  most important point on the chart straddled the top border. The plotted box
  is now inset on both axes by the largest radius the size formula can
  produce, derived from that formula rather than hardcoded, so a future radius
  change can't quietly reintroduce the clipping. Thresholds, the danger rect
  and the dots all read X/Y and shift together; the axis lines use M and stay.
- No dot said which file it was — identity lived only in the hover tooltip.
  The six busiest hot+stale files now carry their basename beside their dot,
  flipped to the dot's other side rather than leaving the frame and stacked
  when two would print on one baseline. Placement is a pure function in
  lib/heat.ts so it can be unit-tested; every dot keeps its title and click.
- "dot size = agent share of reads" was drawn inside the <svg> directly under
  the right-anchored "hot + stale" label, 14px apart. It moved to the panel
  heading row, capped at the chart's own max-width.

The e2e seed grew three hot-but-months-old files: every seeded file was hours
old, so the danger quadrant — the whole reason this panel exists — was empty
in every test that had ever run against it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:32:28 +09:00
fe872dd1fa fix(dashboard): keep reads for a deleted file on the map (BEA-49) (#100)
The Dashboard's file panels built every point by joining the heat map onto
the current file tree, so a heat row whose path had left the project was
silently dropped — while the agent-coverage panel below, which does no such
join, rendered those same reads. One page, one ledger, two answers.

The production consequence is the real bug: delete or rename a well-read doc
and its whole read history vanishes from the map, which is exactly the
signal the Dashboard exists to give.

Hot path now ranks orphaned rows alongside tree files, labelled "no longer
in the project" and opening that path's History (the file view would land on
the not-found page). The two plots stay tree-only — both position by
freshness and an orphan has no mtime, so any position would be invented —
but each carries a count of what it can't show. "No reads in the window yet"
can now only render when the scope genuinely has none.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:11:11 +09:00
1c703c95f8 feat(templates): start a project from a structure, not an empty folder (#97)
* feat(templates): start a project from a structure, not an empty folder

A new project was an empty folder with a .bdriveignore in it, so every agent
session invented its own layout and the folder rotted into a pile. Both
surfaces now offer the same three starting points — from a template, from
scratch, from an existing folder (which is just a non-empty folder, and is
never restructured).

internal/templates holds the shipped set as literal go:embed'ed files: `docs`
(docs/, decisions/) and `para` (projects/, areas/, resources/, archives/).
cmd/bdrive is one binary for the CLI and the hub, so both read the identical
set — no gallery, no drift. The AGENTS.md in each is the deliverable: where a
new note goes, when something is archived, what a good filename looks like.
Every directory holds a real file, because BearDrive syncs paths and an empty
directory would never reach a teammate.

The hub seeds at creation through the existing Upload+Commit path, journaled
under its own device, and records the choice on the project record — so a user
who picked PARA in a browser sees PARA in the browser, and a later init cannot
seed a second copy. `bdrive init --template <name>` goes through the same
endpoint, with a local-seed fallback for a hub too old to know the field, and
seeds in place when re-run in an already-initialized folder (the agent's
post-init path). Seeding never overwrites an existing path, which is what makes
a double-seed a no-op rather than a divergence.

Refusals cost nothing: an unknown name and --template with --only are both
rejected before any network call or write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(cli): joining a project that already has a template is refused by name

The one acceptance case with no test behind it: connecting to an existing
project with --template must say what the project was actually created from,
and must not write the other skeleton on the way out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(templates): name the docs template in plain English, not an acronym

"Plain docs + ADRs" was the recommended, first, preselected-adjacent option in
a picker that non-engineers see — and it's the label people accept without
reading further, so half of it not parsing is the worst place for jargon. The
title also disagreed with its own blurb: "ADRs" over "docs/, decisions/", two
words for the same folder one line apart.

Now "Docs + decision records", which says the same thing to everyone and
matches the folder names. The term itself moves into
decisions/0001-record-decisions.md, where the reader is already inside the
structure and the file can teach it in passing.

One line in the registry drives both the web dialog and the CLI menu; the rest
is prose echoing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(templates): add the LLM wiki template

The third starting point from the issue title, unblocked: the spec parked it
because shipping an approximation under someone's name needed a source, and
there is now one — Karpathy's LLM Wiki gist. Worth noting the issue's own
one-line description of it ("few large, append-heavy topic pages") does not
match the source, which is the opposite: many interlinked pages, where a single
ingest touches 10-15 of them.

The pattern is three layers and three operations, not a folder shape. sources/
is yours and immutable; wiki/ is the agent's and it owns every page; AGENTS.md
is the schema layer — which is exactly the file this template system already
treats as the deliverable, so the fit is direct. index.md and log.md ship as
the two navigation files the pattern turns on.

Three of the things the gist tells you to go set up, BearDrive already is:
version history and collaboration (per-file history, bdrive log), an Obsidian-
style reader for [[wikilinks]] (the hub viewer), and a surface for the lint
pass (the dashboard is literally reads x staleness).

Two rules in the AGENTS.md are load-bearing and deliberate. A page write that
has not updated the index is an incomplete write — a stale index is worse than
a missing page, because it is read first and believed. And with no sources yet,
build nothing: the structure grows out of the material rather than ahead of it.

Shipped second, not first: docs stays the recommendation because a default is
the option chosen by people not reading closely, and this pattern degrades
badly when half-followed. Promoting it later is one line in the registry.

The shipped-template test now checks the "what happens when something stops
being true" question through a set of alternatives — PARA archives, a wiki
supersedes and revises — since the vocabulary honestly differs by structure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(web): "I already have a folder" as a starting point

The third way to start from the spec — adopt what you already have — had no
presence in the browser. Templates and "empty" were the only visible answers,
so someone with a folder of notes either hesitated or picked a template and got
four directories merged into their material.

The constraint that shapes it: the browser cannot reach your disk, so this
cannot change what is created. It creates the same empty project "Empty
project" does; what it changes is the next screen. Create therefore stays
enabled — disabling it would leave the dialog a dead end AND produce no project
id, which is the one thing the paste prompt actually needs.

Landing on the project home with the intent, three things differ: the guide
says "in the folder you already have", a note states plainly that connecting
never moves, renames or overwrites anything, and the paste prompt tells the
agent a folder already exists. That last one is the part that isn't cosmetic —
without it an agent reads an empty project and proposes creating shared/, the
one recommendation that is wrong here. It still asks which folder: that is the
runbook's hard gate and nothing here weakens it.

The intent rides in the URL (?connect=existing) rather than onto the project
record, the same way ?v= pins a file version. It belongs to whoever is
connecting right now — a teammate who connects next week has their own answer
and would be told the wrong thing by a persisted flag.

Five rows made the dialog tall enough to push Create off a short viewport, so
.modal scrolls internally. A hairline divider between the seeding and
non-seeding rows was tried and removed: --border is 7% white, which at 1px in a
gap renders as literally nothing. The gap is the cue that reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(web): with no projects, open the create dialog and give the page a way in

A signed-in account with no projects landed on a page whose only path forward
was pasting a prompt into a coding agent. Now the create dialog opens itself —
with nothing to browse there is nothing else on that page to do — and the page
behind it leads with "Start a project" and a button, so closing the dialog is
not a dead end.

The dialog moves up to HubApp because three things ask for it now: the
sidebar's +, the empty state's button, and the auto-open. ProjectNav keeps only
an onNew callback; one owner beats three copies of the create handler.

Two guards on the auto-open. It fires once per mount, keyed off a ref rather
than the empty state, or closing it would immediately reopen it. And it never
fires on a read-only hub, which refuses creation server-side with a 403 —
opening a dialog that cannot succeed is worse than the page it covers.

The agent paste-prompt stays, demoted to "Or let your agent do it": it is still
the right path for someone who wants the folder connected in the same breath,
and it is the only path on a hub where this account cannot create.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:56:58 +09:00
a05a1f8a52 refactor(auth): one CLI sign-in flow for every provider (webapp.CLIAuth) (#104)
* refactor(auth): one CLI sign-in flow for every provider (webapp.CLIAuth)

The `bdrive login` surface — /auth/cli, /auth/device/<token>, the approval
page both show, /api/auth/exchange and /api/auth/device/{start,poll} — moves
out of BuiltinAuth into its own type. A provider supplies the two things
that actually differ: who the browser session is, and how a device token is
minted.

Nothing changes for a self-hosted hub; this is the same code behind the same
paths. It moves because the managed hub's provider carries its own copy, and
the copy drifted: months after the OSS flow moved to a single approval link
naming the device, that hub was still printing a four-byte code to retype
into a text box. Sharing the implementation is the only fix that stays fixed.

BuiltinAuth's own grant map now holds just what it should: verification and
password-reset links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* fix(architecture): make the webapp-server diagram parse again

Two mermaid syntax errors, so GitHub rendered the first block as an error
box instead of a diagram:

- the CLIAuth class listed its routes as bare lines, and the `{` in
  /auth/device/{token} opens a struct inside a class body — the routes are a
  note now, where prose belongs;
- `note for` strings escaped quotes as \" (mermaid has no backslash escapes,
  so the string ended early). Pre-existing, in the DirectUploader and
  Project notes; both use &quot; now, like the &lt;/&gt; already in there.

Checked by parsing every block in architecture/*.md with mermaid 11.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:42:07 +09:00
af9ef4d181 fix(dashboard): make the knowledge treemap honest on a young project (#93)
* fix(dashboard): make the treemap honest on a young project

Every cell was the same green (staleColor spreads 0-300d, so <=3d-old
content lands in one stop), half the cells were anonymous with no hover
fallback, and read counts existed only inside file <title>s. The map said
nothing the file tree didn't.

- group rects get a <title> (folder, reads/30d, file count), so a group
  whose files are all too small to label is still identifiable
- read counts appended to file and group labels; the count is part of the
  string the fit is measured against, so it can never overflow, and when it
  doesn't fit the label degrades to the bare truncated name as before
- a freshness legend under the map: the gradient plus the age span actually
  observed in this scope+lens, and when that span is under a week it says
  the colour channel carries no signal instead of implying one
- group read totals come from a true sum, not the padded layout value

No relative colour scale: normalising to the observed range would paint a
3-day-old file the red that means hot-and-stale everywhere else on the page.
Sizing (reads + 1), scatter, hot path and coverage are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(architecture): heat.ts joins the frontend lib

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:40:36 +09:00
3d0eee0254 fix(history): size an agent run by files touched, not by ops (BEA-39) (#87)
The run card header counted ops, so a path rewritten five times inflated
the one number a reader uses to size a run: 14 ops across 10 paths read
"14 files". It now counts distinct paths and keeps the word "files";
every op is still a row inside the card.

The same count decided whether a run got a card at all, so a run that hit
one path five times drew a card claiming "5 files". Counting by file
demotes it to bare rows, each still showing its session note.

groupRuns and the Run/Item types move to src/lib/runs.ts (pure, no React)
so node's test runner can import them — a .tsx with JSX can't be. The
grouping key, ordering, time span, who and device are unchanged, and
run.idx still addresses the flat feed so diffs and restore shas are
unaffected. The key's NUL separator moves across as an explicit "\0" —
it was a raw NUL byte in the source, which is also why git saw the old
HistoryView.tsx as binary.

Deviation from the reviewed plan: flipping the threshold in place would
have dropped rows. Only a run's first entry was ever pushed to the output
list, so a demoted 5-op run would have rendered one row, not five.
groupRuns now builds runs first and emits items in a second pass over the
feed, which keeps demoted rows at their own newest-first positions.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:32:20 +09:00
01f33c9fe7 fix(dashboard): the hot-path bar paints share reads as share, not as people (BEA-38) (#86)
* fix(dashboard): the hot-path bar paints share reads as share, not as people

The bar computed one fraction (agent/total) and painted the whole remainder
in the human colour, so a file read only through a share link rendered as if
a person had browsed the hub — and the legend named only two readers, while
the file header has been breaking out all three all along.

Each reader now gets its own segment from its own count. hotPathSplit() does
that arithmetic, and it lives with the rest of the heat helpers in the new
lib/heat.ts (pure, no React) so one unit test over one fixture can pin the
invariant the report doubted: the file header and the Dashboard read the same
total from the same helper. useBrowse.ts re-exports them, so no import site
moved.

No server change — /heat already returns share and stays identity-free.

* docs(architecture): lib gains heat.ts, the one read-count arithmetic

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:23:20 +09:00
4dfe6f44f0 feat(history): undo a file an agent run created (BEA-35) (#82)
History could restore an edit or a deletion, but a file a run CREATED was
the one thing it couldn't reverse — the ADDED row said so in copy and
offered no button. The missing capability was a hub-written delete op:
restore.go only ever journaled puts.

POST /api/p/<id>/remove journals exactly one journal.KindDelete op under
the hub's own device identity, behind restore's gates (gateUpload,
PermWrite, cleanUploadPath, quota CheckWrite/RecordUsage) plus a volume-
snapshot existence check so the API 404s on what the tree doesn't show.
Commit's journal-append tail moves into RemoteSource.appendOp, which both
writes now share — one writer per journal, unchanged.

The ADDED-in-a-run row gets an "undo — remove file" control that confirms
first (it reaches every synced device), and the DELETED row it leaves
behind restores the file with its original bytes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:11:20 +09:00
872ba702cb fix(log): bdrive log reads as a timeline — newest first by edit time (#BEA-40) (#90)
`bdrive log` sorted by the lamport clock, so the wall-clock stamps it prints
came out non-monotonic — two 06:09:24 rows above a 06:10:00 row. And every op
of one scan is stamped with that scan's commit time, so a 22-file agent run
collapsed onto a single stamp. Neither is readable as a timeline, which is the
whole job of the command.

Two display-only changes:

- `journal.Op` gains `Mtime` (`omitzero`, so old journals and old binaries are
  unaffected), populated on put ops from the `os.FileInfo` the scan already
  holds. Deletes and conflict copies keep their commit time.
- `syncer.DisplayTime` / `SortForDisplay` order by the timestamp that is
  actually printed, ties broken by reversed `journal.Less`. `bdrive log` sorts
  and *then* truncates, so `-n 25` is the 25 newest by that stamp.

`journal.Less`, `Sort`, and `Replay` are untouched — replay order is the
convergence contract, so the sort lives in `syncer`, not in `journal`.
`LogEntries` also keeps returning causal order because `bdrive restore` walks
it to find a file's previous version.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:49:23 +09:00
Snow Lee (Sungwon)andGitHub 118abc67e2 feat(cli): "bdrive serve" replaces "bdrive web" (web stays as an alias), and the README leads with the agent install (#94) 2026-07-30 17:46:58 +09:00
Snow Lee (Sungwon)andGitHub 951c6b9de2 feat(viewer): preview text by its bytes, not its extension — and render PDFs (#95)
The viewer decided what to preview from a filename regex, so every
extensionless file an agent writes — Dockerfile, LICENSE, .bdriveignore —
and every unlisted extension (main.tf, schema.graphql) hit a dead "No
preview for this file type." card. That bites hardest in the core use
case: an agent writes an artifact, a teammate opens the hub to read it,
and the hub refuses.

The unmatched path now fetches once and decides on the bytes. The logic
already existed for the history diff — 1 MB cap, Content-Length cheap-out,
8 KB NUL scan, fatal UTF-8 decode — so the pure half moves to
lib/sniff.ts (importable by node --test, no React Query) and both
DiffView and FileView call it. Exactly one sniffer, per the spec.

.pdf gets the browser's own viewer in an iframe, in the wide page column
(768px is unreadable for a PDF page). No sandbox attribute, deliberately:
the PDF viewer is not this page's JS realm, so it cannot reach the hub
API or its cookies, and sandboxing without allow-same-origin breaks
Firefox's pdf.js.

Files that already previewed (md/html/img/.txt) are untouched and issue
no extra fetch.

BEA-44
2026-07-30 16:25:21 +09:00
b927e56fab fix(daemon): let the daemon own its pidfile, so stop can stop it (#91)
* fix(daemon): let the daemon own its pidfile, so stop can stop it

`bdrive stop` could fail with "no such process" and leave sync running.

Liveness became the flock in #88, and Run already announces the child's own
pid only after it holds that lock. But Start still wrote daemon.pid from the
parent, right after fork, with the pid of a child that had not earned
anything yet. Two starts inside that window — `bdrive init` followed by the
login agent's `bdrive resume`, or two resumes close together — race: Running
still reads false, a second child spawns, it loses hold(), and it exits
without ever being the daemon. Its pid is already in the file.

Everything downstream trusts that file. Stop signals the loser and gets
ESRCH, so it reports failure while the winner keeps syncing — the one command
whose job is "stop sending my files" silently does not. status prints the
phantom pid, or "pid 0" when the loser's cleanup removed the file the winner
wrote.

So the parent no longer writes it: the pidfile belongs to whoever holds the
lock. Start now waits for the lock to be taken instead of assuming the spawn
worked, which also means a caller that gets a pid back can trust a daemon
owns it — `bdrive resume` used to print "started (pid N)" for a child that
had already died.

The regression test needs the real binary (Start execs os.Executable), so it
lives with the CLI e2e rather than in internal/daemon, whose tests synthesize
locks. It is deterministic on Linux and roughly one run in five on macOS,
where the window is tighter; `sandbox/run.sh daemon-linux` is the reliable
reproducer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

* docs(architecture): the daemon owns its pidfile, Start only waits for the lock

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:31:44 +09:00