fix(history): stop reporting the device IP on every change (BEA-43) (#81)

* fix(history): stop reporting the device IP on every change (BEA-43)

The history API embedded the whole DeviceInfo, so every project member
read a teammate's server-observed IP (plus user/last_seen) next to every
change on a page whose job is "who changed this file". Project a
three-field historyDevice instead — id/name/os — mirroring heatByDevice.

The device registry is unchanged: Observe/requestIP and both MetaStore
backends keep recording the IP. This is a response projection, not a
change in what gets collected.

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

* docs: history shows device name and OS, not the IP (BEA-43)

README, SKILL.md and the docs site all promised the History view would
show the connecting IP. It no longer does — the registry still records it.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-07-31 09:10:18 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent af9ef4d181
commit b408e004b3
10 changed files with 58 additions and 28 deletions
+3 -3
View File
@@ -91,7 +91,7 @@ actually read (and which hot ones nobody maintains).
device only writes its own append-only journal, so no locking service is
needed; the hub can be backed by any object store.
- **Change tracking** — `bdrive log` and the web UI's History view show
which account changed which file, when, from which device (name, OS, IP).
which account changed which file, when, from which device (name, OS).
Content is stored content-addressed, so every version is retained — view
or download any point in a file's history.
- **Cloud-provider agnostic** — a hub can store on Amazon S3 (`s3://`),
@@ -471,8 +471,8 @@ The web UI lists your orgs' projects in the sidebar (⌘K opens a command
palette: fuzzy file search, project switching, share/history/upload
actions); selecting one browses that project's files, and the **History**
view shows every change — which
account made it, when, from which device (name, OS, and the IP the server
observed), with view/download of any past version (content is
account made it, when, from which device (name and OS — never the connecting
IP), with view/download of any past version (content is
content-addressed and retained forever; reverting to a version is the next
phase and the API is already shaped for it). Folder rows have a history
shortcut for a subtree feed; the topbar button shows the current file's
+3 -2
View File
@@ -11,8 +11,9 @@ import (
// DeviceInfo is what the server knows about one syncing device: self-reported
// name/OS (headers sent by the client), plus what the server itself observed
// (public IP of the last push, last activity, the signed-in account). History
// joins ops against this registry, so IPs are real — as the server saw them —
// and ops stay small.
// joins ops against this registry so ops stay small — but it reports only
// id/name/os (historyDevice, history.go): the IP is recorded here, not
// repeated to every project member on every change.
type DeviceInfo struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
@@ -158,7 +158,6 @@ export interface DeviceInfo {
id?: string;
name?: string;
os?: string;
ip?: string;
}
export interface HistoryEntry {
time: string;
@@ -85,7 +85,7 @@ export function HistoryRow({
const [diffOpen, setDiffOpen] = useState(false);
const kind = e.kind === "put" ? "edit" : e.kind; // older servers report raw "put" ops
const who = whoChanged(e);
const dev = [e.device.name || e.device.id, e.device.os, e.device.ip].filter(Boolean).join(" · ");
const dev = [e.device.name || e.device.id, e.device.os].filter(Boolean).join(" · ");
const clickable = kind !== "delete";
// A delete has no content, and a first version has nothing behind it.
const diffable = !!diff && kind !== "delete" && !!e.blob;
+26 -13
View File
@@ -19,18 +19,29 @@ import (
// the revert/rollback phase, where restoring is just writing an old blob
// back as a new op.
// historyDevice is the slice of the device registry history is allowed to
// report: who/what made the change, not where they connected from. The
// registry keeps observing and persisting the IP (devices.go) — it just
// doesn't ride along here, where every project member reads it. Mirrors
// heatByDevice (reads.go).
type historyDevice struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
OS string `json:"os,omitempty"`
}
// HistoryEntry is one change as the history API reports it.
type HistoryEntry struct {
Time string `json:"time"`
Kind string `json:"kind"` // add | edit | delete
Path string `json:"path"`
Size int64 `json:"size,omitempty"`
Blob string `json:"blob,omitempty"` // sha256; fetch via the blob endpoint
User string `json:"user,omitempty"`
UserName string `json:"user_name,omitempty"`
Author string `json:"author,omitempty"` // offline/git fallback identity
Device DeviceInfo `json:"device"`
Note string `json:"note,omitempty"`
Time string `json:"time"`
Kind string `json:"kind"` // add | edit | delete
Path string `json:"path"`
Size int64 `json:"size,omitempty"`
Blob string `json:"blob,omitempty"` // sha256; fetch via the blob endpoint
User string `json:"user,omitempty"`
UserName string `json:"user_name,omitempty"`
Author string `json:"author,omitempty"` // offline/git fallback identity
Device historyDevice `json:"device"`
Note string `json:"note,omitempty"`
}
// handleHistory serves ?path=<file> (one file's versions) or
@@ -92,9 +103,11 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
case path == "" && prefix != "" && !strings.HasPrefix(op.Path, strings.TrimSuffix(prefix, "/")+"/"):
continue
}
dev, _ := s.Devices.Get(op.Device)
if dev.ID == "" {
dev = DeviceInfo{ID: op.Device, Name: op.DeviceName}
// Unregistered device (or volume mode, where Devices is nil): fall back
// to the op's own id + self-reported name.
dev := historyDevice{ID: op.Device, Name: op.DeviceName}
if info, ok := s.Devices.Get(op.Device); ok && info.ID != "" {
dev = historyDevice{ID: info.ID, Name: info.Name, OS: info.OS}
}
matched = append(matched, timed{HistoryEntry{
Time: op.Time.UTC().Format("2006-01-02T15:04:05Z"), Kind: kinds[i],
+20 -3
View File
@@ -100,10 +100,23 @@ func TestHistoryAPI(t *testing.T) {
if newest.User != "alice@x.io" || newest.UserName != "Alice" {
t.Fatalf("user = %+v", newest)
}
// device joined from the registry: name, OS, server-observed IP
if newest.Device.Name != "alice-laptop" || newest.Device.OS != "darwin/arm64" || newest.Device.IP != "203.0.113.7" {
// device joined from the registry: name and OS only — the server-observed
// IP stays in the registry and out of every member's history feed (BEA-43).
// The id always rides along, so a nameless device still renders as something.
if newest.Device.ID != "dev1" || newest.Device.Name != "alice-laptop" || newest.Device.OS != "darwin/arm64" {
t.Fatalf("device = %+v", newest.Device)
}
// asserted on the raw body, not the struct: a typed unmarshal would pass
// even if the server still emitted the key.
if strings.Contains(rec.Body.String(), "203.0.113.7") {
t.Fatalf("history response leaks the device IP: %s", rec.Body)
}
if strings.Contains(rec.Body.String(), "last_seen") {
t.Fatalf("history response carries registry internals: %s", rec.Body)
}
if d, ok := srv.Devices.Get("dev1"); !ok || d.IP != "203.0.113.7" {
t.Fatalf("registry must keep observing the IP: %+v %v", d, ok)
}
if newest.Blob == "" || oldest.Blob == "" {
t.Fatal("entries must link to their exact content")
}
@@ -124,9 +137,13 @@ func TestHistoryAPI(t *testing.T) {
t.Fatalf("entry before the delete = %+v, want other.md's add", out.Entries[1])
}
// a device the registry never saw falls back to the op's own info
if out.Entries[0].Device.Name != "dev2" {
if out.Entries[0].Device.ID != "dev2" || out.Entries[0].Device.Name != "dev2" {
t.Fatalf("unknown device fallback = %+v", out.Entries[0].Device)
}
// the prefix feed is the same projection — no IP there either
if strings.Contains(rec.Body.String(), "203.0.113.7") {
t.Fatalf("prefix feed leaks the device IP: %s", rec.Body)
}
// whole-project feed + n limit
rec = do(t, h, "GET", base+"history?n=2", nil)
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-CPLO3Qr5.js"></script>
<script type="module" crossorigin src="/assets/index-344uOWlP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CkrUL9C1.css">
</head>
<body>
@@ -94,8 +94,7 @@ bdrive log -n 50 # more of them
```
The web UI's **History** view shows the same thing with view and download of any
past version, including which device (name, OS, and the IP the server observed)
made each change. Folder rows have a history shortcut for a subtree feed.
past version, including which device (name and OS) made each change. Folder rows have a history shortcut for a subtree feed.
This is the part a memory API can't give you: when an agent asserts something,
you can see which agent wrote it, when, from where, and what the file said
@@ -82,7 +82,8 @@ alone can't grant. `bdrive login --device` forces that flow.
Every sync and every `bdrive init` then authenticates with that token. The hub's
device registry records per-device name, OS, account, and the IP the server
observed — that's what History displays.
observed. History shows the device name and OS; the IP stays in the registry
and is never reported to project members.
## Password reset