feat(notify): tell your channel who changed what (BEA-103)

A teammate's agent writes a decision into runbook.md, it lands in your
folder in fifteen seconds, and nothing tells you. Two halves of the fix:

post_sync's payload gains `user` and `note` per changed entry, so a
local recipe can post "Dana's Claude updated shared/findings/eu-checkout.md"
instead of "1 file changed" — the copy was blocked by the payload, not by
formatting. journal.LastOps is what makes it possible: Replay drops deleted
paths and FileState carries no author, so deletes had nothing to attribute
to. Less, Replay and FileState are untouched.

And a hub-side webhook: one optional Webhook on Project, admin-set, https
on Slack/Teams hosts only, never returned by any API. It fires AFTER the
response from both places an op is born — handleStorePut and
RemoteSource.OnCommit at the end of appendOps, which covers uploads,
removes, restores and undo-run in one field. Goroutine, bounded client,
log once, no retry queue: a slow Slack must never 502 a push, because the
client retries a 502 and the retry re-fires the webhook.

A project with no webhook set produces zero new behavior and zero new
outbound requests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee
2026-08-19 09:21:46 -07:00
co-authored by Claude Opus 5
parent 31db19b9c6
commit 20a99a8a3c
31 changed files with 1153 additions and 95 deletions
+14 -2
View File
@@ -301,10 +301,22 @@ applied batch arrives as JSON on stdin:
```json
{ "project": "m-5a10b713", "folder": "/Users/you/notes",
"changed": [ { "path": "wiki/onboarding.md", "op": "write" },
{ "path": "notes/retired.md", "op": "delete" } ] }
"changed": [ { "path": "wiki/onboarding.md", "op": "write",
"user": "Dana Kim", "note": "claude session 41f2" },
{ "path": "notes/retired.md", "op": "delete",
"user": "Sam Ito" } ] }
```
`user` is who committed the change — the signed-in account's name, falling
back to its email and then to the device's git/OS identity, the same order
`bdrive log` prints. `note` carries the agent session when an agent wrote it,
so a recipe can post *"Dana Kim's Claude updated …"* rather than *"1 file
changed"*. Both are omitted when unknown, so a hook written before they
existed keeps working unchanged. Note that `note` is user-settable
(`bdrive sync --note`) and display-only: it names an agent, it never proves
one. There is a ready-made Slack/Teams recipe in
[the docs](https://docs.beardrive.ai/guides/slack-notifications/).
Once per cycle that applied at least one path (an initial sync of 400 files is
one invocation), inbound only — a cycle that just commits and pushes your own
edits fires nothing — and never blocking: the command is spawned detached, and
+9 -2
View File
@@ -37,6 +37,7 @@ classDiagram
note for Session "pull returns TWO lists: newly seen ops, and `gone` — ops a peer deleted from a journal this device had already applied. A peer cannot un-say what we already hold: stillHold re-signs each still-held put into OUR journal (reassertNote). Pull resumes at a byte offset by prefix-matching the local journal copy, so a peer's growing journal is read once"
note for Session "Bounds and hardening applied throughout: sizeBound/pullBound cap a fetch against the op's declared size, maxPeerJournals caps how many peers one cycle reads, absorbLamport/tickLamport refuse an absurd peer clock and saturate instead of wrapping, safeMode masks a materialized file to 0777 &^ 0022 (no setuid/setgid, no group/other write), and safeDevice + os.SameFile keep a peer's device id from naming this device's own journal file"
note for Session "Cycle is now a thin wrapper: cycleLocked does today's work under the volume flock, then firePostSync spawns the folder's post_sync command AFTER the lock drops, so a hook that runs a bdrive command starts working instead of blocking on the flock the cycle still holds. Splitting the body out is what makes that a property of the code rather than a rule every call site remembers"
note for Session "attrib is journal.LastOps(all) — the winning op per path, deletes INCLUDED, taken once per cycle right after Replay and before anything materializes (the .bdriveignore write included). Replay throws deletes away and FileState carries no author, so this is the only thing that can name who a materialized change came from; logInbound reads it to stamp User/Note on every event"
note for Session "logInbound records each materialized peer path BOTH ways — onto Result.Inbound for the post_sync hook (same process as the cycle) and onto the store's inbound spool for bdrive sync --hook (a later process). DrainInbound is destructive and single-consumer: the hook must never drain it"
note for Session "Prune (bdrive forget / sync --prune, never the daemon) journals a delete for every replayed path the SHARED ignore rules exclude — the include scope is per-device and must never prune it"
@@ -49,7 +50,7 @@ classDiagram
+Reason() string
}
note for Result "Adopted counts the paths a JOINING device gave to the project (Cycle step 1b). A first cycle is a join, not an edit: a local file at a path the project already holds is demoted to lamport 0 (adoptNote), so the project's version wins on every device and no conflict copy is made — the local content stays journaled and pushed, which is what bdrive restore reads"
note for Result "Inbound names the paths this cycle applied on a peer's behalf — what firePostSync hands the post_sync command as JSON on stdin. Empty on a scan-and-push cycle, which is why a local-edit-only cycle fires nothing"
note for Result "Inbound names the paths this cycle applied on a peer's behalf — what firePostSync hands the post_sync command as JSON on stdin. Empty on a scan-and-push cycle, which is why a local-edit-only cycle fires nothing. Each event now also carries User and Note, so a recipe can post `Dana's Claude updated path` rather than `1 file changed` — the payload constraint was the whole reason that copy was impossible"
note for Result "Offline / ReadOnly / NoAccess are three different answers: unreachable (retry all), push refused (pull-only), pull refused (pause, touch nothing)"
note for Result "Reason() is accessReason(AccessErr): the hub's own sentence for a refusal, minus the wrapper chain, dropped unless it passes journal.SafeText. 'read-only' summarizes the STATUS CODE — the sentence is the only thing that tells a device-registration 403 from a project the user really is a reader on"
@@ -136,7 +137,7 @@ classDiagram
+SaveNote / LoadNote
+LogRead(rel, session) read spool
+PendingReads dedup on path+session
+LogInbound / DrainInbound
+LogInbound(InboundEvent) / DrainInbound
+LoadSecrets / SaveSecrets mountID
+Lock() flock
}
@@ -162,6 +163,11 @@ classDiagram
+Session agent session, hook-set
+Mtime when the file was written
}
class LastOps {
<<journal, func>>
+LastOps(ops) map path to Op
}
note for LastOps "The winning op per path under the SAME total order Replay folds with, deletes included — a max-scan, not a sort, so it agrees with Replay by construction and costs O(n) rather than a third sort per cycle. Display/attribution only: Less, Replay and FileState are untouched, so the determinism invariant is not in play. Replay cannot answer this — it drops a deleted path entirely, and FileState is {Blob,Size,Mode} with no author in it"
note for Op "internal/journal — Less orders by (lamport, time, device, seq); Replay folds to LWW-per-path state; each device writes only its own journal. Mtime is display-only (bdrive log shows it, falling back to Time) and never feeds Less or Replay. Session holds the same standing: set only by `bdrive sync --hook` (never by --note, which any member can spell), display/join-only, and the key History run cards group on — a note is forgeable, a session id is not"
note for Op "Op now owns its own JSON: a Path that is not valid UTF-8 rides as a base64 `path_raw` sidecar and is restored only when the lossy form still matches, so one line can never name two different files on two readers. Less falls through to Kind/Path/Blob/Size/Mode, making the order TOTAL — two ops can no longer tie and replay differently per device. Parse skips an undecodable line and drops an unknown Kind instead of failing the whole journal"
@@ -203,6 +209,7 @@ classDiagram
Session --> secretLog : scan flags, finish persists
secretLog --> Store : reads the blob, writes secrets-mount-id.json
Session ..> Op : commits, replays
Session ..> LastOps : attrib — who committed each materialized path
Session --> Result
Store o-- Op : journal files
Store *-- SyncState : sync.json
+1
View File
@@ -98,6 +98,7 @@ classDiagram
note for components "HistoryFilters drives the SERVER (?q=/?user=/?since=/?until= on the history API), never the loaded page — filtering what is on screen would lie about everything below the fold and break next_cursor. Its state is Route.filters, so a narrowed feed is linkable, survives reload, and Back undoes it; the author list accumulates across fetches, because filtering by one author leaves only their rows loaded"
note for components "FileView's transformHTML resolves the server's `wiki:` marker against flatFiles into a real urlForPath() href (unresolvable ones lose the href and get .wiki-missing), so copy-link/middle-click/new-tab work and only a plain click reaches the delegated handler — resolution used to happen at click time, which left a dead `wiki:guide` string in the DOM (BEA-136). It also drops `data:image/svg` from any rendered img and any `data:` href from any rendered link — goldmark admits them, and an inline SVG is a document rather than a picture (the same property the server's sandboxInline walls off). Insights builds its per-device folder bag with Object.create(null), since folder names come off a peer's journal and one named __proto__ silently emptied the matrix. style.css sets unicode-bidi isolate-override on the peer-authored strings a reader is expected to CHECK (listing rows, breadcrumb, history path/note/device) — journal.SafeText refuses the bidi CONTROLS, but a single strong-RTL LETTER is legal and still reorders a row"
note for components "HistoryView's RunGroup header carries the run-wide undo (POST undo-run, gated by the same write permission as the per-row restore/remove). It asks the SERVER for the file list first (preview: true) rather than deriving it from the loaded feed — that window is paged and filterable, so a client-computed list is wrong exactly when the run is old. modal.tsx's Confirm.message widened from string to ReactNode for it (the prompt's one-field API is untouched), so the dialog can show every path, its action, and the &quot;changed after this run&quot; warning inline"
note for components "ProjectSettings' General card gains the change-notification field, rendered only for mayEdit (project admins) — the entire UI surface this feature has: no nav entry, no prompt, no empty-state card, so a solo user never meets it. The input shows STATE, not content: the server never returns the URL, so the field is always blank on load, resets to blank after a save, and the saved-ness is an `On` chip plus a Turn off button driven by Project.webhook_set. Its zod rule mirrors checkWebhookURL's https + host allowlist so a typo does not round-trip."
note for components "components/ui — shadcn/ui primitives (Radix, copied in), themed from BearDrive tokens in tw.css; rendered markdown is transformed as a string before mounting, link clicks delegated on the container — never patch the dangerouslySetInnerHTML subtree"
class lib {
+16
View File
@@ -54,6 +54,7 @@ classDiagram
+Backend remote.Backend
+Device Identity
+PresignTTL time.Duration
+OnCommit func([]journal.Op)
+Remove(ctx, path, who, note)
+OpenBlob(ctx, sha)
-reassemble(ctx, sha) chunked fallback
@@ -252,6 +253,7 @@ classDiagram
+Template string
+Default string
+Perms map email→level
+Webhook string notify endpoint
}
class seedTemplate {
<<Server method>>
@@ -261,6 +263,7 @@ classDiagram
skips paths that already exist
CheckWrite / RecordUsage
}
note for Project "Webhook is a CREDENTIAL and never leaves the server: projectJSON zeroes it beside Perms/Default and reports webhook_set instead, which is what keeps the &quot;never leaves&quot; rule true for a projectView that embeds Project precisely so new fields DO reach the client. PermAdmin to set or clear (handleProjectUpdate), https + a Slack/Teams host allowlist at set time — a URL the hub then fetches on an admin&#39;s say-so is an SSRF primitive — and empty (the default) means the project makes no outbound requests at all. It persists through the existing ProjectRepo.Put, which for the SQL backend means a `webhook` column in addColumns and in all three column-naming statements: PutMeta names columns one by one, so a missing column is a SILENT drop that is green on the file backend and lost on Postgres."
note for Project "Default == &quot;&quot; means write — the historical behavior, so an upgraded hub needs no migration. SetPerm/ClearPerm refuse to drop the last explicit admin."
class projectPerm {
@@ -412,6 +415,15 @@ classDiagram
}
note for productAnalytics "The events the browser cannot see: a device syncing through /store/* never loads a page, so an agent editing files all day is invisible to the frontend's tracker. Every write door — sync, upload, remove, restore — funnels through captureChange so the file-change count is ONE event rather than a per-route set that silently misses whichever route someone forgets, and distinct_id is the same email analytics.ts identifies with so a person is not counted twice. No SDK: posthog-go would ship a tracker inside every self-hoster's binary, and capture is one JSON POST to Endpoint() + /i/v0/e/ that does nothing at all when Key is empty. Telemetry never fails a request — the POST is a goroutine and its error is a single log line. countOps needs journalDoor's storedMax because a device PUTs its WHOLE journal every cycle: counting the body would re-report the device's entire history every ten seconds. Blob PUTs are deliberately not change events, since content-addressed storage skips a blob it already holds."
class changeNotifier {
<<Server, notify.go>>
notifyProject(id, ops) AFTER the response
notifyText(ops) pure, table-testable
newOps(ops, storedMax) new ops only
notifyClient 10s · notifyWarnOnce
}
note for changeNotifier "Modeled on productAnalytics line for line, for the same one reason: it must never fail a request. Respond first, notify second — a synchronous POST turns a slow Slack into a 502 on a device push, and the client RETRIES a 502, which re-fires the webhook. Bounded client, own goroutine, log once, no retry queue. It fires from BOTH places an op is born: journalDoor after writeJSON, and RemoteSource.OnCommit at the end of appendOps — which is the single funnel for upload, remove, restore AND undo-run, so one field covers a write path a per-handler wiring would have missed. newOps reuses countOps' storedMax filter for the same reason countOps needs it: a device PUTs its whole journal every cycle, so notifying on the body posts the project's entire history to the channel every ten seconds, per device. The copy is the feature — every line names an actor (UserName → User → Author) and Op.Note&#39;s `&lt;platform&gt; session &lt;id&gt;` turns it into `Dana&#39;s Claude updated path`; the note is user-settable and display-only, so it names an agent and never proves one."
Server o-- "0..1" Source : single-volume mode
Server o-- "0..1" Backend : Root (hub mode)
Server o-- ProjectDB
@@ -444,6 +456,10 @@ classDiagram
Server *-- productAnalytics : every write door emits files_changed
productAnalytics ..> AnalyticsConfig : Key gates it, Endpoint() addresses it
journalDoor ..> productAnalytics : storedMax tells this cycle's ops from the whole history
Server *-- changeNotifier : fires when Project.Webhook is set
journalDoor ..> changeNotifier : after writeJSON, never before
RemoteSource ..> changeNotifier : OnCommit — the hub's own writes count too
changeNotifier ..> Project : reads Webhook, or does nothing at all
Server *-- volume : per project, cached
volume o-- Source
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/secrets"
@@ -323,7 +324,7 @@ func seedInbound(t *testing.T, proj config.Project, paths ...string) {
}
for _, p := range paths {
deleted := strings.HasPrefix(p, "-")
if err := st.LogInbound(strings.TrimPrefix(p, "-"), deleted); err != nil {
if err := st.LogInbound(store.InboundEvent{Path: strings.TrimPrefix(p, "-"), Deleted: deleted, Time: time.Now().UTC()}); err != nil {
t.Fatal(err)
}
}
+19
View File
@@ -394,3 +394,22 @@ func Append(path string, ops []Op) error {
_, err = f.Write(data)
return err
}
// LastOps returns the winning op per path under the same total order Replay
// folds with — deletes included, which is the half Replay cannot give you
// (it drops a deleted path from the state entirely). Display/attribution
// only: nothing here feeds Less or Replay, so the determinism invariant is
// untouched.
//
// A max-scan rather than a sort: Less is a total order, so keeping the
// greatest op per path agrees with Replay by construction and costs O(n)
// instead of another sort of every op in the volume, per cycle.
func LastOps(ops []Op) map[string]Op {
last := make(map[string]Op, len(ops))
for _, op := range ops {
if prev, ok := last[op.Path]; !ok || Less(prev, op) {
last[op.Path] = op
}
}
return last
}
+39
View File
@@ -128,3 +128,42 @@ func TestMtimeIsAdditive(t *testing.T) {
t.Fatalf("Mtime should be zero, got %v", got[1].Mtime)
}
}
// TestLastOpsAgreesWithReplay pins LastOps to Replay: every path Replay keeps
// must resolve to an op with the same blob, and a deleted path — which Replay
// drops — must resolve to its delete op.
func TestLastOpsAgreesWithReplay(t *testing.T) {
base := time.Unix(1700000000, 0).UTC()
ops := []Op{
{Seq: 1, Lamport: 1, Time: base, Device: "a", Kind: KindPut, Path: "keep.md", Blob: "aaa"},
{Seq: 2, Lamport: 5, Time: base.Add(time.Second), Device: "a", Kind: KindPut, Path: "keep.md", Blob: "bbb", UserName: "Dana Kim"},
{Seq: 1, Lamport: 2, Time: base, Device: "b", Kind: KindPut, Path: "gone.md", Blob: "ccc"},
{Seq: 2, Lamport: 6, Time: base.Add(2 * time.Second), Device: "b", Kind: KindDelete, Path: "gone.md", UserName: "Sam Ito"},
{Seq: 3, Lamport: 3, Time: base, Device: "b", Kind: KindPut, Path: "other.md", Blob: "ddd"},
}
// Shuffled input must not change the answer.
shuffled := []Op{ops[3], ops[0], ops[4], ops[2], ops[1]}
state := Replay(shuffled)
last := LastOps(shuffled)
for path, fs := range state {
op, ok := last[path]
if !ok {
t.Fatalf("LastOps missing %q that Replay kept", path)
}
if op.Kind != KindPut || op.Blob != fs.Blob {
t.Fatalf("LastOps[%q] = %+v, want the put with blob %q", path, op, fs.Blob)
}
}
if _, ok := state["gone.md"]; ok {
t.Fatal("Replay kept a deleted path")
}
del, ok := last["gone.md"]
if !ok || del.Kind != KindDelete || del.UserName != "Sam Ito" {
t.Fatalf("LastOps[gone.md] = %+v, want Sam Ito's delete", del)
}
if got := last["keep.md"].UserName; got != "Dana Kim" {
t.Fatalf("LastOps[keep.md].UserName = %q, want Dana Kim", got)
}
}
+14 -4
View File
@@ -27,6 +27,13 @@ type InboundEvent struct {
Path string `json:"path"`
Deleted bool `json:"deleted,omitempty"`
Time time.Time `json:"time"`
// User is who committed the winning op, resolved the way `bdrive log`
// prints it (UserName -> User -> Author). Note is that op's note, which
// the agent hook stamps "<platform> session <id>". Both display-only and
// both omitempty, so a consumer written against the older shape is
// unaffected.
User string `json:"user,omitempty"`
Note string `json:"note,omitempty"`
}
// inboundSpoolMax caps the spool: a machine with no agent hooks never drains,
@@ -39,13 +46,16 @@ const inboundDrainMax = 4096
func (s *Store) inboundSpoolPath() string { return filepath.Join(s.dir, "inbound.jsonl") }
func (s *Store) inboundDrainPath() string { return filepath.Join(s.dir, "inbound-draining.jsonl") }
// LogInbound appends one materialized path to the spool. Single-line O_APPEND
// writes keep the daemon and a concurrent CLI cycle from interleaving.
func (s *Store) LogInbound(rel string, deleted bool) error {
// LogInbound appends one materialized event to the spool. Single-line
// O_APPEND writes keep the daemon and a concurrent CLI cycle from
// interleaving. It takes the whole event rather than its fields so the spool
// and syncer.Result.Inbound cannot drift: the caller builds one value and
// both consumers see it.
func (s *Store) LogInbound(e InboundEvent) error {
if fi, err := os.Stat(s.inboundSpoolPath()); err == nil && fi.Size() > inboundSpoolMax {
return nil // spool full: drop, never grow unbounded
}
line, err := json.Marshal(InboundEvent{Path: rel, Deleted: deleted, Time: time.Now().UTC()})
line, err := json.Marshal(e)
if err != nil {
return err
}
+9 -8
View File
@@ -4,6 +4,7 @@ import (
"os"
"strings"
"testing"
"time"
)
func TestInboundSpool(t *testing.T) {
@@ -14,14 +15,14 @@ func TestInboundSpool(t *testing.T) {
t.Fatalf("empty spool = %v, %v", evs, err)
}
if err := s.LogInbound("wiki/a.md", false); err != nil {
if err := s.LogInbound(InboundEvent{Path: "wiki/a.md", Deleted: false, Time: time.Now().UTC()}); err != nil {
t.Fatal(err)
}
if err := s.LogInbound("b.md", false); err != nil {
if err := s.LogInbound(InboundEvent{Path: "b.md", Deleted: false, Time: time.Now().UTC()}); err != nil {
t.Fatal(err)
}
// A path written and then removed reports as deleted: latest wins.
if err := s.LogInbound("b.md", true); err != nil {
if err := s.LogInbound(InboundEvent{Path: "b.md", Deleted: true, Time: time.Now().UTC()}); err != nil {
t.Fatal(err)
}
evs, err := s.DrainInbound()
@@ -47,14 +48,14 @@ func TestInboundSpool(t *testing.T) {
func TestInboundSpoolSurvivesCorruptLines(t *testing.T) {
s := openTestStore(t)
s.LogInbound("good.md", false)
s.LogInbound(InboundEvent{Path: "good.md", Deleted: false, Time: time.Now().UTC()})
f, err := os.OpenFile(s.inboundSpoolPath(), os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
t.Fatal(err)
}
f.WriteString(`{"path": "torn`) // a torn write
f.Close()
s.LogInbound("also-good.md", false)
s.LogInbound(InboundEvent{Path: "also-good.md", Deleted: false, Time: time.Now().UTC()})
evs, err := s.DrainInbound()
if err != nil {
t.Fatal(err)
@@ -70,7 +71,7 @@ func TestInboundSpoolCap(t *testing.T) {
s := openTestStore(t)
long := strings.Repeat("d", 1024)
for i := 0; i < 1100; i++ { // ~1.1 MB of events
if err := s.LogInbound(long+"/"+string(rune('a'+i%26))+".md", false); err != nil {
if err := s.LogInbound(InboundEvent{Path: long + "/" + string(rune('a'+i%26)) + ".md", Deleted: false, Time: time.Now().UTC()}); err != nil {
t.Fatal(err)
}
}
@@ -87,7 +88,7 @@ func TestInboundSpoolCap(t *testing.T) {
// folder, never synced.
func TestInboundSpoolPermissions(t *testing.T) {
s := openTestStore(t)
if err := s.LogInbound("a.md", false); err != nil {
if err := s.LogInbound(InboundEvent{Path: "a.md", Deleted: false, Time: time.Now().UTC()}); err != nil {
t.Fatal(err)
}
fi, err := os.Stat(s.inboundSpoolPath())
@@ -108,7 +109,7 @@ func TestInboundSpoolUnreadableRecovers(t *testing.T) {
if _, err := s.DrainInbound(); err == nil {
t.Fatal("unreadable spool should report its error")
}
if err := s.LogInbound("a.md", false); err != nil {
if err := s.LogInbound(InboundEvent{Path: "a.md", Deleted: false, Time: time.Now().UTC()}); err != nil {
t.Fatal(err)
}
evs, err := s.DrainInbound()
+8 -1
View File
@@ -20,6 +20,13 @@ type postSyncPayload struct {
type postSyncChanged struct {
Path string `json:"path"`
Op string `json:"op"` // "write" | "delete"
// User is who committed the change, resolved UserName -> User -> Author
// the way `bdrive log` prints it; Note is that op's note, which the agent
// hook stamps "<platform> session <id>" so a recipe can render
// "Dana's Claude updated <path>". Both omitempty: a hook written against
// the older path/op payload is unaffected.
User string `json:"user,omitempty"`
Note string `json:"note,omitempty"`
}
// firePostSync runs the folder's post_sync command once, for a cycle that
@@ -45,7 +52,7 @@ func (s *Session) firePostSync(res *Result) {
if e.Deleted {
op = "delete"
}
payload.Changed = append(payload.Changed, postSyncChanged{Path: e.Path, Op: op})
payload.Changed = append(payload.Changed, postSyncChanged{Path: e.Path, Op: op, User: e.User, Note: e.Note})
}
body, err := json.Marshal(payload)
if err != nil {
+78
View File
@@ -241,3 +241,81 @@ func TestPostSyncLeavesInboundSpool(t *testing.T) {
t.Fatalf("inbound spool = %v, want wiki/page.md still queued for the agent hook", evs)
}
}
// TestPostSyncPayloadNamesTheAuthor is the whole point of the payload
// change: a recipe hung off post_sync must be able to write "Dana's Claude
// updated <path>" rather than "1 file changed". Two devices through a shared
// remote — B commits with a signed-in account and a session note, A's cycle
// materializes and fires the hook.
func TestPostSyncPayloadNamesTheAuthor(t *testing.T) {
rem := sharedRemote(t)
a, b := newDevice(t, "deva", rem), newDevice(t, "devb", rem)
b.Account = config.Settings{Email: "dana@example.com", Name: "Dana Kim"}
b.Note = "claude session 41f2"
marker := filepath.Join(t.TempDir(), "marker.json")
postSync(t, a.Folder, "cat >> "+marker)
write(t, b.Folder, "shared/findings/eu-checkout.md", "we are dropping Redis")
cycle(t, b)
cycle(t, a)
got := batch(t, waitForFile(t, marker, 5*time.Second))
if len(got.Changed) != 1 {
t.Fatalf("batch = %+v, want one path", got.Changed)
}
if c := got.Changed[0]; c.User != "Dana Kim" || c.Note != "claude session 41f2" {
t.Fatalf("changed[0] = %+v, want user Dana Kim / note claude session 41f2", c)
}
}
// A delete carries the same attribution — journal.Replay drops the path, so
// this is the case that would silently ship blank without journal.LastOps.
func TestPostSyncDeletesCarryTheAuthor(t *testing.T) {
rem := sharedRemote(t)
a, b := newDevice(t, "deva", rem), newDevice(t, "devb", rem)
b.Account = config.Settings{Email: "sam@example.com", Name: "Sam Ito"}
write(t, b.Folder, "runbook.md", "v1")
cycle(t, b)
cycle(t, a) // A has the file
marker := filepath.Join(t.TempDir(), "marker.json")
postSync(t, a.Folder, "cat >> "+marker)
if err := os.Remove(filepath.Join(b.Folder, "runbook.md")); err != nil {
t.Fatal(err)
}
cycle(t, b)
cycle(t, a)
got := batch(t, waitForFile(t, marker, 5*time.Second))
if len(got.Changed) != 1 || got.Changed[0].Op != "delete" {
t.Fatalf("batch = %+v, want one delete", got.Changed)
}
if got.Changed[0].User != "Sam Ito" {
t.Fatalf("delete user = %q, want Sam Ito", got.Changed[0].User)
}
}
// With no signed-in account the payload falls back to Device.Author, the same
// precedence `bdrive log` prints.
func TestPostSyncFallsBackToAuthor(t *testing.T) {
rem := sharedRemote(t)
a, b := newDevice(t, "deva", rem), newDevice(t, "devb", rem)
marker := filepath.Join(t.TempDir(), "marker.json")
postSync(t, a.Folder, "cat >> "+marker)
write(t, b.Folder, "notes.md", "hi")
cycle(t, b)
cycle(t, a)
got := batch(t, waitForFile(t, marker, 5*time.Second))
if len(got.Changed) != 1 || got.Changed[0].User != "devb@test" {
t.Fatalf("batch = %+v, want user devb@test from Device.Author", got.Changed)
}
if got.Changed[0].Note != "" {
t.Fatalf("note = %q, want empty for a plain daemon push", got.Changed[0].Note)
}
}
+23 -3
View File
@@ -98,6 +98,10 @@ type Session struct {
// Result.Inbound. Reset at the top of every cycle — a Session is reused
// across cycles by the daemon and by the tests.
inbound []store.InboundEvent
// attrib is this cycle's winning op per path (journal.LastOps), so every
// materialized path can name who committed it. Reset alongside inbound;
// filled once the merged journal is read, before anything materializes.
attrib map[string]journal.Op
}
// logInbound records one materialized peer path both ways: on this cycle's
@@ -106,8 +110,20 @@ type Session struct {
// later process). Both consumers want the same event; neither may consume the
// other's copy.
func (s *Session) logInbound(rel string, deleted bool) {
s.inbound = append(s.inbound, store.InboundEvent{Path: rel, Deleted: deleted, Time: time.Now().UTC()})
_ = s.Store.LogInbound(rel, deleted) // best-effort: never fails a cycle
e := store.InboundEvent{Path: rel, Deleted: deleted, Time: time.Now().UTC()}
// Name the author, the way `bdrive log` prints it (cmd/bdrive/cmds.go).
// Deletes get it too: LastOps keeps delete ops, which Replay throws away.
if op, ok := s.attrib[rel]; ok {
e.User, e.Note = op.UserName, op.Note
if e.User == "" {
e.User = op.User
}
if e.User == "" {
e.User = op.Author
}
}
s.inbound = append(s.inbound, e)
_ = s.Store.LogInbound(e) // best-effort: never fails a cycle
}
func (s *Session) mountID() string {
@@ -258,7 +274,7 @@ func (s *Session) cycleLocked(ctx context.Context) (*Result, error) {
}
defer unlock()
s.inbound = nil
s.inbound, s.attrib = nil, nil
res := &Result{}
cache, err := s.Store.LoadCache(s.mountID())
if err != nil {
@@ -495,6 +511,10 @@ func (s *Session) cycleLocked(ctx context.Context) (*Result, error) {
return nil, fmt.Errorf("read journals: %w", err)
}
target := journal.Replay(all)
// Attribution for everything materialized below — including the
// .bdriveignore write a few lines down, which is why this sits above it
// rather than beside the delete loop.
s.attrib = journal.LastOps(all)
// The ignore rules sync like any other file, so a peer can receive the new
// .bdriveignore and the delete ops it justifies in the same batch. The
+8 -6
View File
@@ -12,11 +12,12 @@ import (
// operable — an admin can offboard, clean up, and audit — without editing
// JSON files on the server by hand.
// handleProjectUpdate edits a project's name, description and icon. Project
// admins (and, implicitly, the owners of its org) only. It's a partial update:
// every field is a pointer, so only the keys actually present in the body
// change — {"description":""} clears the description, omitting the key leaves
// it alone.
// handleProjectUpdate edits a project's name, description, icon and
// notification webhook. Project admins (and, implicitly, the owners of its
// org) only. It's a partial update: every field is a pointer, so only the
// keys actually present in the body change — {"description":""} clears the
// description, omitting the key leaves it alone. {"webhook":""} clears the
// webhook, which is how notifications are turned back off.
func (s *Server) handleProjectUpdate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("project")
if _, ok := s.project(w, r, id, PermAdmin); !ok {
@@ -26,12 +27,13 @@ func (s *Server) handleProjectUpdate(w http.ResponseWriter, r *http.Request) {
Name *string `json:"name"`
Description *string `json:"description"`
Icon *string `json:"icon"`
Webhook *string `json:"webhook"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
if err := s.Projects.Update(id, req.Name, req.Description, req.Icon); err != nil {
if err := s.Projects.Update(id, req.Name, req.Description, req.Icon, req.Webhook); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
+1 -1
View File
@@ -197,7 +197,7 @@ func checkToken(t authToken) error { return storable(t.Hash, t.User, t.Device) }
func checkProject(p Project) error {
if err := storable(p.ID, p.Name, p.Org, p.Description, p.Icon,
p.Creator, p.Template, p.Default); err != nil {
p.Creator, p.Template, p.Default, p.Webhook); err != nil {
return err
}
return storableMap(p.Perms)
+8 -1
View File
@@ -140,7 +140,8 @@ func TestMetaStoreConformance(t *testing.T) {
t.Fatal(err)
}
desc, icon := "everything support needs", "book-open"
if err := projects.Update(p1.ID, nil, &desc, &icon); err != nil {
hook := "https://hooks.slack.com/services/T0/B0/conformance"
if err := projects.Update(p1.ID, nil, &desc, &icon, &hook); err != nil {
t.Fatal(err)
}
p2, _, _ := projects.GetOrCreate("scratch", "o-1")
@@ -282,6 +283,12 @@ func TestMetaStoreConformance(t *testing.T) {
if hb.Creator != "boss@x.io" || hb.Default != PermNone {
t.Fatalf("creator/default lost across reload: %+v", hb)
}
// The webhook is the field a SQL backend drops silently: PutMeta
// names its columns one by one, so a column the schema lacks is
// simply not written — green on the file backend, gone on Postgres.
if hb.Webhook != "https://hooks.slack.com/services/T0/B0/conformance" {
t.Fatalf("webhook did not survive a reopen: %q", hb.Webhook)
}
if hb.Template != "para" {
t.Fatalf("template lost across reload: %+v", hb)
}
+17 -10
View File
@@ -295,6 +295,11 @@ func (s *sqlMetaStore) migrate() error {
"creator": `TEXT NOT NULL DEFAULT ''`,
"default_level": `TEXT NOT NULL DEFAULT ''`,
"template": `TEXT NOT NULL DEFAULT ''`,
// Unguarded on purpose, unlike default_level above: empty means "no
// webhook", so a rollback or a half-applied migration fails CLOSED —
// the project stops notifying. The opposite of the default_level
// hazard, and the safe direction for an outbound credential.
"webhook": `TEXT NOT NULL DEFAULT ''`,
}, map[string]string{
"default_level": "it silently re-opens every restricted project to its whole organization",
}); err != nil {
@@ -507,7 +512,7 @@ func (r *sqlProjectRepo) Version() (string, error) { return r.s.version(regProje
func (r *sqlProjectRepo) Load() ([]Project, error) {
rows, err := r.s.db.Query(
`SELECT id, name, org, created, description, icon, creator, default_level, template FROM projects`)
`SELECT id, name, org, created, description, icon, creator, default_level, template, webhook FROM projects`)
if err != nil {
return nil, err
}
@@ -517,7 +522,7 @@ func (r *sqlProjectRepo) Load() ([]Project, error) {
var p Project
var created string
if err := rows.Scan(&p.ID, &p.Name, &p.Org, &created,
&p.Description, &p.Icon, &p.Creator, &p.Default, &p.Template); err != nil {
&p.Description, &p.Icon, &p.Creator, &p.Default, &p.Template, &p.Webhook); err != nil {
rows.Close()
return nil, err
}
@@ -567,12 +572,13 @@ func (r *sqlProjectRepo) Put(p Project) error {
}
return r.s.inTx(regProjects, func(tx *sql.Tx) error {
if _, err := tx.Exec(r.s.q(
`INSERT INTO projects (id,name,org,created,description,icon,creator,default_level,template)
VALUES (?,?,?,?,?,?,?,?,?)
`INSERT INTO projects (id,name,org,created,description,icon,creator,default_level,template,webhook)
VALUES (?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, org=excluded.org, created=excluded.created,
description=excluded.description, icon=excluded.icon,
creator=excluded.creator, default_level=excluded.default_level, template=excluded.template`),
p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default, p.Template); err != nil {
creator=excluded.creator, default_level=excluded.default_level, template=excluded.template,
webhook=excluded.webhook`),
p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default, p.Template, p.Webhook); err != nil {
return err
}
if _, err := tx.Exec(r.s.q(`DELETE FROM project_perms WHERE project = ?`), p.ID); err != nil {
@@ -594,12 +600,13 @@ func (r *sqlProjectRepo) PutMeta(p Project) error {
if err := checkProject(p); err != nil {
return err
}
return r.w.exec(`INSERT INTO projects (id,name,org,created,description,icon,creator,default_level,template)
VALUES (?,?,?,?,?,?,?,?,?)
return r.w.exec(`INSERT INTO projects (id,name,org,created,description,icon,creator,default_level,template,webhook)
VALUES (?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, org=excluded.org, created=excluded.created,
description=excluded.description, icon=excluded.icon,
creator=excluded.creator, default_level=excluded.default_level, template=excluded.template`,
p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default, p.Template)
creator=excluded.creator, default_level=excluded.default_level, template=excluded.template,
webhook=excluded.webhook`,
p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default, p.Template, p.Webhook)
}
// PutPerm writes one grant row. An empty level removes it.
@@ -77,6 +77,12 @@ export interface Project {
// server-side. A project you cannot read never appears in the list at all,
// so this is always read or better here.
perm?: PermLevel;
/**
* Whether a change-notification webhook is configured. The URL itself is a
* credential and never leaves the server (projectJSON, server.go), so this
* is state to render there is no value to round-trip.
*/
webhook_set?: boolean;
}
// GET /api/p/{id}/permissions (handleProjectPerms, perms.go)
@@ -33,6 +33,22 @@ import type { Org, PermLevel, Project, ProjectPerms } from "../api/types";
const MAX_DESC = 280;
// Mirrors the server's allowlist (checkWebhookURL, projects.go) so a typo
// never round-trips. The server is the authority — this only saves a round
// trip and explains the rule where it is typed.
const WEBHOOK_HOSTS = ["hooks.slack.com", ".webhook.office.com", ".logic.azure.com"];
function webhookOK(raw: string): boolean {
let u: URL;
try {
u = new URL(raw);
} catch {
return false;
}
if (u.protocol !== "https:") return false;
const host = u.hostname.toLowerCase();
return WEBHOOK_HOSTS.some((h) => (h.startsWith(".") ? host.endsWith(h) : host === h));
}
// Mirrors the server's rules (projects.go) so a typo never round-trips.
const schema = z.object({
name: z
@@ -42,6 +58,10 @@ const schema = z.object({
.max(120, "Keep the name under 120 characters."),
description: z.string().max(MAX_DESC, `Keep the description under ${MAX_DESC} characters.`),
icon: z.string(),
webhook: z
.string()
.trim()
.refine((v) => v === "" || webhookOK(v), "Paste a Slack or Teams incoming-webhook https:// URL."),
});
type Values = z.infer<typeof schema>;
@@ -65,6 +85,9 @@ export function ProjectSettings({
name: project.name,
description: project.description ?? "",
icon: project.icon ?? "",
// Always blank: the server never sends the URL back, so there is
// nothing to seed and a placeholder would read as a stored value.
webhook: "",
},
});
// Switching projects (or a refresh bringing new values) re-seeds the form,
@@ -74,6 +97,7 @@ export function ProjectSettings({
name: project.name,
description: project.description ?? "",
icon: project.icon ?? "",
webhook: "",
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project.id, project.name, project.description, project.icon]);
@@ -89,11 +113,14 @@ export function ProjectSettings({
if (dirty.name) body.name = values.name.trim();
if (dirty.description) body.description = values.description;
if (dirty.icon) body.icon = values.icon;
if (dirty.webhook) body.webhook = values.webhook.trim();
if (Object.keys(body).length === 0) return;
try {
await api("PATCH", "/api/projects/" + project.id, body);
toast("Saved.");
form.reset({ ...values, name: values.name.trim() }); // clean, keeps what was typed
// webhook back to blank, not to what was typed: the field shows state,
// not content, and leaving the URL on screen is a credential on screen.
form.reset({ ...values, name: values.name.trim(), webhook: "" });
await refresh(); // nav mark + dashboard header update without a reload
} catch (e) {
toast((e as Error).message, true); // form left alone, so nothing is lost
@@ -110,7 +137,7 @@ export function ProjectSettings({
<Card>
<CardHeader>
<CardTitle>General</CardTitle>
<CardDescription>Name, description and icon for this project.</CardDescription>
<CardDescription>Name, description, icon and notifications for this project.</CardDescription>
</CardHeader>
<Separator />
<CardContent>
@@ -201,6 +228,60 @@ export function ProjectSettings({
</div>
</div>
{/* Admin-only, and the whole surface this feature has: no nav
entry, no prompt, no empty state. A solo user never opens it. */}
{mayEdit && (
<div className="ps-field">
<Label htmlFor="ps-webhook">
Change notifications <span className="ps-opt">(optional)</span>
{project.webhook_set && <span className="ps-chip">On</span>}
</Label>
<Input
id="ps-webhook"
type="url"
placeholder={
project.webhook_set
? "Paste a new URL to replace the current one"
: "https://hooks.slack.com/services/…"
}
aria-invalid={!!form.formState.errors.webhook}
aria-describedby="ps-webhook-help"
{...form.register("webhook")}
/>
<div className="ps-meta">
{form.formState.errors.webhook ? (
<span role="alert" className="field-err">
{form.formState.errors.webhook.message}
</span>
) : (
<span id="ps-webhook-help" className="ps-opt">
{project.webhook_set
? "Changes post to your channel. The saved URL is never shown again."
: "Post every change to a Slack or Teams channel."}
</span>
)}
{project.webhook_set && (
<Button
id="ps-webhook-clear"
type="button"
variant="subtle"
onClick={async () => {
try {
await api("PATCH", "/api/projects/" + project.id, { webhook: "" });
toast("Notifications off.");
await refresh();
} catch (e) {
toast((e as Error).message, true);
}
}}
>
Turn off
</Button>
)}
</div>
</div>
)}
{mayEdit && (
<>
<Separator />
+15 -13
View File
@@ -68,7 +68,7 @@ func TestProjectLifecycle(t *testing.T) {
// Partial update: only the fields you pass move.
ptr := func(s string) *string { return &s }
if err := db.Update(p.ID, nil, ptr("the team handbook"), ptr("book-open")); err != nil {
if err := db.Update(p.ID, nil, ptr("the team handbook"), ptr("book-open"), nil); err != nil {
t.Fatal(err)
}
got, _ := db.Get(p.ID)
@@ -76,14 +76,14 @@ func TestProjectLifecycle(t *testing.T) {
t.Fatalf("update: %+v", got)
}
// icon-only update leaves name and description alone
if err := db.Update(p.ID, nil, nil, ptr("users")); err != nil {
if err := db.Update(p.ID, nil, nil, ptr("users"), nil); err != nil {
t.Fatal(err)
}
if got, _ = db.Get(p.ID); got.Name != "handbook" || got.Description != "the team handbook" || got.Icon != "users" {
t.Fatalf("icon-only update: %+v", got)
}
// present-and-empty clears; absent does not
if err := db.Update(p.ID, nil, ptr(""), ptr("")); err != nil {
if err := db.Update(p.ID, nil, ptr(""), ptr(""), nil); err != nil {
t.Fatal(err)
}
if got, _ = db.Get(p.ID); got.Description != "" || got.Icon != "" || got.Name != "handbook" {
@@ -91,18 +91,20 @@ func TestProjectLifecycle(t *testing.T) {
}
for _, tc := range []struct {
what string
name, desc, icon *string
what string
name, desc, icon, webhook *string
}{
{"empty name", ptr(" "), nil, nil},
{"name over 120", ptr(strings.Repeat("x", 121)), nil, nil},
{"sibling collision", ptr("docs"), nil, nil},
{"description over 280", nil, ptr(strings.Repeat("d", 281)), nil},
{"icon uppercase", nil, nil, ptr("Folder")},
{"icon with space", nil, nil, ptr("a b")},
{"icon over 32", nil, nil, ptr(strings.Repeat("a", 33))},
{"empty name", ptr(" "), nil, nil, nil},
{"name over 120", ptr(strings.Repeat("x", 121)), nil, nil, nil},
{"sibling collision", ptr("docs"), nil, nil, nil},
{"description over 280", nil, ptr(strings.Repeat("d", 281)), nil, nil},
{"icon uppercase", nil, nil, ptr("Folder"), nil},
{"icon with space", nil, nil, ptr("a b"), nil},
{"icon over 32", nil, nil, ptr(strings.Repeat("a", 33)), nil},
{"webhook not https", nil, nil, nil, ptr("http://hooks.slack.com/services/x")},
{"webhook off-allowlist", nil, nil, nil, ptr("https://evil.example.com/x")},
} {
if err := db.Update(p.ID, tc.name, tc.desc, tc.icon); err == nil {
if err := db.Update(p.ID, tc.name, tc.desc, tc.icon, tc.webhook); err == nil {
t.Fatalf("%s: expected an error", tc.what)
}
}
+154
View File
@@ -0,0 +1,154 @@
package webapp
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/runbear-io/beardrive/internal/journal"
)
// Change notifications: when a project has a Webhook set, the hub posts one
// Slack-shaped message per journal write naming who changed what.
//
// This is modeled on analytics.go, deliberately and line for line, because it
// has the same one hard requirement: it must never fail a request. The POST
// runs on its own goroutine after the response is written, behind a bounded
// client, and a failure logs once and is forgotten. A synchronous POST here
// would turn a slow Slack into a 502 on a device push — and the client
// retries a 502, which re-fires the webhook.
//
// No retry queue and no coalescing window: a delivery that fails is gone. A
// notifier that guarantees delivery is a queue with durability, which is a
// different feature.
// notifyClient bounds a hung or black-holed endpoint, so a goroutine cannot
// be pinned for the life of the process.
var notifyClient = &http.Client{Timeout: 10 * time.Second}
// notifyWarnOnce logs the first delivery failure and nothing after it — a hub
// whose channel endpoint is dead would otherwise write a line per sync cycle
// per device forever, about something no operator can act on from the log.
// Once, not a bool: these run on their own goroutines.
var notifyWarnOnce sync.Once
// notifyProject posts the ops as one message to the project's webhook, or
// does nothing at all when none is set — which is every project by default,
// so an unconfigured hub makes zero new outbound requests.
//
// Call it AFTER the response is written. It returns immediately.
func (s *Server) notifyProject(id string, ops []journal.Op) {
if s.Projects == nil || len(ops) == 0 {
return
}
p, ok := s.Projects.Get(id)
if !ok || p.Webhook == "" {
return
}
text := notifyText(ops)
if text == "" {
return
}
body, err := json.Marshal(map[string]string{"text": text})
if err != nil {
return
}
url := p.Webhook
go func() {
resp, err := notifyClient.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
notifyFailed(err)
return
}
resp.Body.Close()
}()
}
// notifyLineMax caps one message. A first cycle on a fresh mount materializes
// the whole project, so the honest failure is "…and N more", not a wall.
const notifyLineMax = 20
// agentPlatforms are the platforms the agent hook stamps into Op.Note as
// "<platform> session <id>" (internal/agenthooks). Matching it is what turns
// "Dana updated x.md" into "Dana's Claude updated x.md" — the line GTM asked
// for, and the reason this posts agent activity rather than file activity.
var agentPlatforms = map[string]string{
"claude": "Claude", "codex": "Codex", "gemini": "Gemini", "hermes": "Hermes",
}
// notifyText renders one batch as the message body. Pure, so it is
// table-testable without a server.
//
// The copy is the feature: "1 file changed" is a 2015 file-sync notification
// and says nothing a person can act on. Every line names an actor.
func notifyText(ops []journal.Op) string {
// One line per path, latest op wins — a batch can carry several ops for
// the same file.
last := journal.LastOps(ops)
paths := make([]string, 0, len(last))
for path := range last {
paths = append(paths, path)
}
sort.Strings(paths)
var b strings.Builder
for i, path := range paths {
if i > 0 {
b.WriteByte('\n')
}
if i == notifyLineMax {
fmt.Fprintf(&b, "…and %d more", len(paths)-notifyLineMax)
break
}
b.WriteString(notifyLine(last[path]))
}
return b.String()
}
func notifyLine(op journal.Op) string {
who := opActor(op)
if agent := agentOf(op.Note); agent != "" {
who += "'s " + agent
}
verb := "updated"
if op.Kind == journal.KindDelete {
verb = "deleted"
}
return fmt.Sprintf("%s %s %s", who, verb, op.Path)
}
// opActor is the display name for an op, the same precedence `bdrive log`
// prints and the post_sync payload carries: the signed-in account first, the
// git/OS identity as the offline fallback.
func opActor(op journal.Op) string {
for _, s := range []string{op.UserName, op.User, op.Author} {
if s != "" {
return s
}
}
return "Someone"
}
// agentOf reads the agent platform out of a note the agent hook stamped, and
// returns "" for anything else — including a note a member typed by hand
// (`bdrive sync --note`). The note is display-only and never proof: it names
// an agent, it does not establish one.
func agentOf(note string) string {
fields := strings.Fields(note)
if len(fields) < 2 || fields[1] != "session" {
return ""
}
return agentPlatforms[strings.ToLower(fields[0])]
}
func notifyFailed(err error) {
notifyWarnOnce.Do(func() {
log.Printf("beardrive: change notification delivery failed (further failures silent): %v", err)
})
}
+329
View File
@@ -0,0 +1,329 @@
package webapp
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/runbear-io/beardrive/internal/journal"
)
// TestNotifyText pins the copy, which is the feature: every line names an
// actor, and "1 file changed" appears nowhere.
func TestNotifyText(t *testing.T) {
for _, tc := range []struct {
name string
ops []journal.Op
want string
}{{
name: "agent note names the platform",
ops: []journal.Op{{
Seq: 1, Lamport: 1, Kind: journal.KindPut, Path: "shared/findings/eu-checkout.md",
UserName: "Dana Kim", Note: "claude session 41f2",
}},
want: "Dana Kim's Claude updated shared/findings/eu-checkout.md",
}, {
name: "plain daemon push has no agent",
ops: []journal.Op{{
Seq: 1, Lamport: 1, Kind: journal.KindPut, Path: "runbook.md", UserName: "Dana Kim",
}},
want: "Dana Kim updated runbook.md",
}, {
name: "falls back to the git identity",
ops: []journal.Op{{
Seq: 1, Lamport: 1, Kind: journal.KindPut, Path: "runbook.md", Author: "dana@laptop",
}},
want: "dana@laptop updated runbook.md",
}, {
name: "a hand-typed note is not an agent",
ops: []journal.Op{{
Seq: 1, Lamport: 1, Kind: journal.KindPut, Path: "a.md",
UserName: "Sam Ito", Note: "tidying up",
}},
want: "Sam Ito updated a.md",
}, {
name: "deletes read as deletes",
ops: []journal.Op{{
Seq: 1, Lamport: 1, Kind: journal.KindDelete, Path: "old.md", UserName: "Sam Ito",
}},
want: "Sam Ito deleted old.md",
}, {
name: "the last op per path wins",
ops: []journal.Op{
{Seq: 1, Lamport: 1, Kind: journal.KindPut, Path: "a.md", UserName: "Dana Kim"},
{Seq: 2, Lamport: 2, Kind: journal.KindDelete, Path: "a.md", UserName: "Sam Ito"},
},
want: "Sam Ito deleted a.md",
}} {
t.Run(tc.name, func(t *testing.T) {
got := notifyText(tc.ops)
if got != tc.want {
t.Fatalf("notifyText = %q, want %q", got, tc.want)
}
if strings.Contains(got, "file changed") {
t.Fatalf("notifyText says %q — the copy is the feature", got)
}
})
}
}
// A big first cycle must not post a wall of lines.
func TestNotifyTextCapsTheBatch(t *testing.T) {
var ops []journal.Op
for i := range 50 {
ops = append(ops, journal.Op{
Seq: int64(i + 1), Lamport: int64(i + 1), Kind: journal.KindPut,
Path: fmt.Sprintf("f%02d.md", i), UserName: "Dana Kim",
})
}
got := notifyText(ops)
lines := strings.Split(got, "\n")
if len(lines) != notifyLineMax+1 {
t.Fatalf("got %d lines, want %d plus the overflow line", len(lines), notifyLineMax)
}
if want := fmt.Sprintf("…and %d more", 50-notifyLineMax); lines[notifyLineMax] != want {
t.Fatalf("last line = %q, want %q", lines[notifyLineMax], want)
}
}
// setWebhookDirect bypasses the host allowlist, which (correctly) refuses an
// httptest host. The delivery tests are about the delivery path; the gate has
// its own test.
func setWebhookDirect(t *testing.T, db *ProjectDB, id, url string) {
t.Helper()
db.mu.Lock()
defer db.mu.Unlock()
db.refresh()
p, ok := db.byID[id]
if !ok {
t.Fatalf("no such project %q", id)
}
next := p
next.Webhook = url
if err := db.put(p, next); err != nil {
t.Fatal(err)
}
}
// uploadFile drives the real two-step browser upload.
func uploadFile(t *testing.T, h http.Handler, id, path, content string, c *http.Cookie) {
t.Helper()
rec := doAs(t, h, "POST", "/api/p/"+id+"/upload/init", initReq(path, content), c)
if rec.Code != 200 {
t.Fatalf("upload init: %d %s", rec.Code, rec.Body)
}
rec = doAs(t, h, "PUT", "/api/p/"+id+"/upload/content?path="+path, []byte(content), c)
if rec.Code != 200 {
t.Fatalf("upload content: %d %s", rec.Code, rec.Body)
}
}
// setWebhook points a project at url as an admin, through the real endpoint.
func setWebhook(t *testing.T, h http.Handler, id, url string, c *http.Cookie) {
t.Helper()
rec := doAs(t, h, "PATCH", "/api/projects/"+id, map[string]string{"webhook": url}, c)
if rec.Code != 200 {
t.Fatalf("set webhook: %d %s", rec.Code, rec.Body)
}
}
// TestWebhookNeverLeavesTheServer is the AC that inverts if projectJSON
// forgets to zero the field: projectView embeds Project, so a new field ships
// to every reader by default. Asserted on the RAW body — a decode into
// Project would pass while the JSON carries the URL.
func TestWebhookNeverLeavesTheServer(t *testing.T) {
h, _, cookies, p := permHub(t)
const secret = "https://hooks.slack.com/services/T0/B0/zzSECRETzz"
setWebhook(t, h, p.ID, secret, cookies["alice"])
for _, req := range []struct{ method, url string }{
{"GET", "/api/projects"},
{"GET", "/api/projects/" + p.ID},
} {
rec := doAs(t, h, req.method, req.url, nil, cookies["alice"])
body := rec.Body.String()
if strings.Contains(body, "zzSECRETzz") || strings.Contains(body, "hooks.slack.com") {
t.Fatalf("%s %s leaked the webhook URL: %s", req.method, req.url, body)
}
if !strings.Contains(body, `"webhook_set":true`) {
t.Fatalf("%s %s does not report webhook_set: %s", req.method, req.url, body)
}
}
// And the create response, the third site that renders a project.
rec := doAs(t, h, "POST", "/api/projects", map[string]string{"name": "wiki"}, cookies["alice"])
if strings.Contains(rec.Body.String(), "zzSECRETzz") {
t.Fatalf("create response leaked the webhook URL: %s", rec.Body)
}
}
// Setting or clearing the webhook is admin-only; a plain member is refused.
func TestWebhookSetIsAdminOnly(t *testing.T) {
h, srv, cookies, p := permHub(t)
if err := srv.Projects.SetPerm(p.ID, "bob@x.io", PermWrite); err != nil {
t.Fatal(err)
}
if err := srv.Projects.SetPerm(p.ID, "carol@x.io", PermRead); err != nil {
t.Fatal(err)
}
for _, who := range []string{"bob", "carol", "dave"} {
rec := doAs(t, h, "PATCH", "/api/projects/"+p.ID,
map[string]string{"webhook": "https://hooks.slack.com/services/T0/B0/x"}, cookies[who])
if rec.Code != 403 && rec.Code != 404 {
t.Fatalf("%s set webhook: %d %s, want refused", who, rec.Code, rec.Body)
}
}
if got, _ := srv.Projects.Get(p.ID); got.Webhook != "" {
t.Fatalf("webhook = %q after refused writes, want empty", got.Webhook)
}
}
// An admin-set URL the hub then fetches is an SSRF primitive. Only https on
// the Slack/Teams incoming-webhook hosts is accepted, and a rejection names
// the reason.
func TestWebhookRejectsNonSlackHosts(t *testing.T) {
h, srv, cookies, p := permHub(t)
for _, bad := range []string{
"http://hooks.slack.com/services/T0/B0/x", // scheme
"https://169.254.169.254/latest/meta-data/", // the reason the check exists
"https://evil.example.com/x", // host
"https://hooks.slack.com.evil.example/x", // suffix trick
"file:///etc/passwd",
} {
rec := doAs(t, h, "PATCH", "/api/projects/"+p.ID, map[string]string{"webhook": bad}, cookies["alice"])
if rec.Code != 400 {
t.Fatalf("PATCH webhook=%q: %d, want 400", bad, rec.Code)
}
if body := rec.Body.String(); !strings.Contains(body, "https") && !strings.Contains(body, "host") {
t.Fatalf("PATCH webhook=%q rejected without a reason: %s", bad, body)
}
}
for _, good := range []string{
"https://hooks.slack.com/services/T0/B0/x",
"https://acme.webhook.office.com/webhookb2/abc",
"https://prod-12.westus.logic.azure.com/workflows/abc/triggers/manual/paths/invoke",
} {
setWebhook(t, h, p.ID, good, cookies["alice"])
if got, _ := srv.Projects.Get(p.ID); got.Webhook != good {
t.Fatalf("webhook = %q, want %q", got.Webhook, good)
}
}
// Empty clears it — the way notifications get turned off.
setWebhook(t, h, p.ID, "", cookies["alice"])
if got, _ := srv.Projects.Get(p.ID); got.Webhook != "" {
t.Fatalf("webhook = %q after clear, want empty", got.Webhook)
}
}
// hangingHook is an endpoint that never answers until the test releases it —
// the shape a real outage takes.
func hangingHook(t *testing.T) (url string, release func(), got chan string) {
t.Helper()
block, delivered := make(chan struct{}), make(chan string, 8)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Text string `json:"text"`
}
json.NewDecoder(r.Body).Decode(&body)
delivered <- body.Text
<-block
}))
var once bool
t.Cleanup(func() {
if !once {
close(block)
}
srv.Close()
})
return srv.URL, func() { once = true; close(block) }, delivered
}
// TestWebhookNeverDelaysAPush is the invariant this whole feature is gated
// on: the response is written first and the POST happens after, so a hanging
// endpoint cannot stall — or 502 — a sync.
func TestWebhookNeverDelaysAPush(t *testing.T) {
h, srv, cookies, p, _ := permHubAt(t)
hookURL, release, delivered := hangingHook(t)
defer release()
// Set the URL directly: the allowlist (correctly) refuses an httptest
// host, and what is under test here is the delivery path, not the gate.
setWebhookDirect(t, srv.Projects, p.ID, hookURL)
done := make(chan struct{}, 1)
go func() {
uploadFile(t, h, p.ID, "notes.md", "hello from the browser", cookies["alice"])
done <- struct{}{}
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("the upload blocked on the hanging webhook — it must respond first, notify second")
}
select {
case text := <-delivered:
if !strings.Contains(text, "notes.md") {
t.Fatalf("delivered %q, want it to name notes.md", text)
}
case <-time.After(5 * time.Second):
t.Fatal("no notification was delivered")
}
}
// The hub notifies about its own writes, or "the hub tells you" is false for
// anyone who only ever uses the browser.
func TestWebhookFiresFromBrowserWrites(t *testing.T) {
h, srv, cookies, p, _ := permHubAt(t)
hookURL, release, delivered := hangingHook(t)
release() // answer immediately; this test is about coverage, not timing
setWebhookDirect(t, srv.Projects, p.ID, hookURL)
uploadFile(t, h, p.ID, "notes.md", "v1", cookies["alice"])
if text := waitForText(t, delivered); !strings.Contains(text, "updated notes.md") {
t.Fatalf("upload notified %q", text)
}
rec := doAs(t, h, "POST", "/api/p/"+p.ID+"/remove",
map[string]string{"path": "notes.md"}, cookies["alice"])
if rec.Code != 200 {
t.Fatalf("remove: %d %s", rec.Code, rec.Body)
}
if text := waitForText(t, delivered); !strings.Contains(text, "deleted notes.md") {
t.Fatalf("remove notified %q", text)
}
}
// A project with no webhook makes no outbound request at all.
func TestNoWebhookNoRequests(t *testing.T) {
h, _, cookies, p, _ := permHubAt(t)
hits := make(chan struct{}, 4)
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits <- struct{}{}
}))
defer hook.Close()
uploadFile(t, h, p.ID, "a.md", "x", cookies["alice"])
select {
case <-hits:
t.Fatal("an unconfigured project made an outbound request")
case <-time.After(300 * time.Millisecond):
}
}
func waitForText(t *testing.T, ch chan string) string {
t.Helper()
select {
case s := <-ch:
return s
case <-time.After(5 * time.Second):
t.Fatal("no notification was delivered")
return ""
}
}
+1 -1
View File
@@ -403,7 +403,7 @@ func TestReadMemberSeesSharesAndGrants(t *testing.T) {
func TestProjectListCarriesWholeProject(t *testing.T) {
h, srv, c, p := permHub(t)
desc, icon := "everything support needs", "book-open"
if err := srv.Projects.Update(p.ID, nil, &desc, &icon); err != nil {
if err := srv.Projects.Update(p.ID, nil, &desc, &icon, nil); err != nil {
t.Fatal(err)
}
rec := doAs(t, h, "GET", "/api/projects", nil, c["alice"])
+60 -3
View File
@@ -3,6 +3,7 @@ package webapp
import (
"fmt"
"log"
"net/url"
"regexp"
"sort"
"strings"
@@ -38,6 +39,14 @@ type Project struct {
Default string `json:"default,omitempty"`
// Perms are the explicit grants, lowercase email → level.
Perms map[string]string `json:"perms,omitempty"`
// Webhook is an incoming-webhook URL notified when the hub journals a
// change to this project (notify.go). Empty — the default — means the
// project produces no notifications and no outbound requests at all.
//
// It is a credential: anyone holding it can post into the team's channel.
// It never leaves the server — projectJSON zeroes it and reports
// `webhook_set` instead, the same treatment share tokens get.
Webhook string `json:"webhook,omitempty"`
}
// level is the project's effective default level for org members.
@@ -284,8 +293,8 @@ func (db *ProjectDB) GetOrCreate(name, org string) (Project, bool, error) {
// that "absent" (nil, leave alone) is distinguishable from "present and
// empty" (clear it) — the whole point of a partial update. One lock, one
// repo write, whatever the caller changed.
func (db *ProjectDB) Update(id string, name, description, icon *string) error {
var newName, newDesc, newIcon string
func (db *ProjectDB) Update(id string, name, description, icon, webhook *string) error {
var newName, newDesc, newIcon, newHook string
if name != nil {
newName = trimText(projectLabel(*name), maxNameLen+1)
if newName == "" {
@@ -307,6 +316,12 @@ func (db *ProjectDB) Update(id string, name, description, icon *string) error {
return fmt.Errorf("invalid icon name %q", newIcon)
}
}
if webhook != nil {
var err error
if newHook, err = checkWebhookURL(*webhook); err != nil {
return err
}
}
db.mu.Lock()
defer db.mu.Unlock()
@@ -330,12 +345,54 @@ func (db *ProjectDB) Update(id string, name, description, icon *string) error {
if icon != nil {
next.Icon = newIcon
}
if webhook != nil {
next.Webhook = newHook
}
return db.put(p, next)
}
// maxWebhookLen bounds what an admin can store; real incoming-webhook URLs
// are well under 200 bytes.
const maxWebhookLen = 512
// webhookHosts is the allowlist. An admin-supplied URL the hub then fetches
// is an SSRF primitive — it reaches whatever the hub can reach, including a
// cloud metadata endpoint — and a host check closes that completely for three
// lines. Slack, the retired-O365 replacement for Teams (Power Automate
// Workflows) and the legacy Teams connector host are what the feature is for;
// widening to a generic webhook is a later change behind a real egress policy.
var webhookHosts = []string{"hooks.slack.com", ".webhook.office.com", ".logic.azure.com"}
// checkWebhookURL normalizes and validates an incoming-webhook URL. The empty
// string is valid and means "clear it".
func checkWebhookURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
if len(raw) > maxWebhookLen {
return "", fmt.Errorf("webhook URL must be at most %d characters", maxWebhookLen)
}
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("webhook URL is not a URL: %w", err)
}
if u.Scheme != "https" {
return "", fmt.Errorf("webhook URL must use https, not %q", u.Scheme)
}
host := strings.ToLower(u.Hostname())
for _, allowed := range webhookHosts {
if host == allowed || (strings.HasPrefix(allowed, ".") && strings.HasSuffix(host, allowed)) {
return raw, nil
}
}
return "", fmt.Errorf("webhook host %q is not a Slack or Teams incoming-webhook host "+
"(allowed: hooks.slack.com, *.webhook.office.com, *.logic.azure.com)", host)
}
// Rename changes a project's display name (its id and storage are permanent).
func (db *ProjectDB) Rename(id, name string) error {
return db.Update(id, &name, nil, nil)
return db.Update(id, &name, nil, nil, nil)
}
// Delete removes a project from the registry. Its storage prefix (blobs,
+22 -2
View File
@@ -306,6 +306,9 @@ func (s *Server) projectVolume(id string) (Project, *volume, error) {
// The real TTL the presign doors hand out, so verify seals a
// blob no earlier than the last URL for it can expire.
PresignTTL: s.Upload.ttl(),
// The hub notifies about its own writes too, or "the hub tells
// you" is false for everyone who never leaves the browser.
OnCommit: func(ops []journal.Op) { s.notifyProject(id, ops) },
},
refresh: s.Refresh,
}
@@ -329,6 +332,15 @@ type RemoteSource struct {
// the server's real UploadConfig.ttl(), or a longer configured TTL would
// seal an object that can still change.
PresignTTL time.Duration
// OnCommit, when set, is called with the ops a successful appendOps just
// journaled — the hub telling itself about its own writes. appendOps is
// the single funnel for browser uploads, removes, restores and run undo,
// so this one field covers every hub-side write path; hanging the call
// off each handler instead would miss whichever one is added next.
//
// It is called after the journal is stored, and must not block: the
// notifier it feeds returns immediately.
OnCommit func([]journal.Op)
upmu sync.Mutex // serializes read-modify-write of our own journal
// sealed holds the blobs this process has verified AND proved immutable.
@@ -1065,13 +1077,21 @@ func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) {
type projectView struct {
Project
Perm string `json:"perm"`
// WebhookSet reports whether notifications are configured without ever
// naming the endpoint — the URL is a credential, so it is state the UI
// renders, not content it round-trips.
WebhookSet bool `json:"webhook_set,omitempty"`
}
func projectJSON(p Project, perm string) projectView {
// The grant list and the default belong to /api/p/{id}/permissions, which
// has its own gate; they'd be noise on every row of every project list.
p.Perms, p.Default = nil, ""
return projectView{p, perm}
// The webhook URL is a credential and leaves the server for nobody, admin
// included — projectView embeds Project precisely so new fields reach the
// client, so a field that must not must be zeroed HERE.
set := p.Webhook != ""
p.Perms, p.Default, p.Webhook = nil, "", ""
return projectView{p, perm, set}
}
func (s *Server) handleProjectGet(w http.ResponseWriter, r *http.Request) {
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-CYEvhG3F.js"></script>
<script type="module" crossorigin src="/assets/index-C8V9fKpr.js"></script>
<link rel="modulepreload" crossorigin href="/assets/_commonjsHelpers-CqkleIqs.js">
<link rel="modulepreload" crossorigin href="/assets/mermaid-DQuCJ8Gi.js">
<link rel="stylesheet" crossorigin href="/assets/index-C20TgXSV.css">
+18
View File
@@ -709,6 +709,7 @@ func (s *Server) handleStorePut(v *volume, w http.ResponseWriter, r *http.Reques
return
}
var storedMax int64
var fresh []journal.Op // ops the hub had not seen, notified after the response
if strings.HasPrefix(key, "journal/") {
var ok bool
var err error
@@ -810,6 +811,23 @@ func (s *Server) handleStorePut(v *volume, w http.ResponseWriter, r *http.Reques
v.invalidate() // new ops should show in the viewer immediately
puts, deletes := countOps(ops, storedMax)
s.captureChange(r, "sync", puts, deletes)
fresh = newOps(ops, storedMax)
}
writeJSON(w, map[string]any{"ok": true})
// AFTER the response, never before: a synchronous POST to a slow endpoint
// would 502 this push, and the client retries a 502.
s.notifyProject(project, fresh)
}
// newOps is countOps' filter, kept: a device PUTs its WHOLE journal every
// cycle, so notifying on `ops` rather than the ops above storedMax posts the
// project's entire history to the channel every ten seconds, per device.
func newOps(ops []journal.Op, storedMax int64) []journal.Op {
var out []journal.Op
for _, op := range ops {
if op.Seq > storedMax {
out = append(out, op)
}
}
return out
}
+9 -1
View File
@@ -245,7 +245,15 @@ func (r *RemoteSource) appendOps(ctx context.Context, ops []journal.Op) error {
return err
}
data := append(existing, line...)
return r.Backend.Put(ctx, key, strings.NewReader(string(data)), int64(len(data)))
if err := r.Backend.Put(ctx, key, strings.NewReader(string(data)), int64(len(data))); err != nil {
return err
}
// Only a stored journal is a change. OnCommit must not block — see its
// doc on RemoteSource.
if r.OnCommit != nil {
r.OnCommit(ops)
}
return nil
}
var errBlobMissing = fmt.Errorf("content not uploaded yet")
+1
View File
@@ -139,6 +139,7 @@ export default defineConfig({
{ label: "Artifacts and links", slug: "guides/agent-artifacts" },
{ label: "What agents read", slug: "guides/what-agents-read" },
{ label: "Scoping the folder", slug: "guides/scoping" },
{ label: "Notify Slack or Teams", slug: "guides/slack-notifications" },
],
},
{
@@ -0,0 +1,135 @@
---
title: Tell Slack what your agents changed
description: Post every teammate and agent change to a Slack or Teams channel — either locally with post_sync, or hub-side with a project webhook.
---
A teammate's agent writes *"we're dropping Redis for Postgres"* into
`runbook.md`. It lands in your folder in about fifteen seconds. Nothing tells
you. BearDrive answers "who changed this" perfectly once you go looking — the
point of this page is to stop you having to look.
There are two ways to do it, and they cover different people.
| | Runs where | Covers | Needs |
| -- | -- | -- | -- |
| **`post_sync`** | your device | everything that lands in *your* folder | a daemon running |
| **Project webhook** | the hub | every write to the project, including browser uploads | a project admin |
Set up whichever matches who needs telling. They are independent; a project
can have both.
## The copy that matters
The message should name the actor, not the file count:
```
Dana Kim's Claude updated shared/findings/eu-checkout.md
Dana Kim updated shared/findings/eu-checkout.md
Sam Ito deleted notes/retired.md
```
The first line is an agent write — the agent sync hook stamps the platform
into the op's note. The second is a plain daemon push with no agent involved.
"1 file changed" tells nobody anything; every recipe below names a person.
One caveat, stated plainly: the note is **user-settable**
(`bdrive sync --note "…"`) and **display-only**. It names an agent; it never
proves one. Treat it as a label, not as attribution you would act on.
## Option 1 — `post_sync` on your device
Create an [incoming webhook](https://api.slack.com/messaging/webhooks) in
Slack (or a Workflows webhook in Teams) and copy the URL. Then point your
folder's `post_sync` at a script:
```jsonc
// .bdrive/config.json
{ "id": "m-5a10b713", "volume": "notes",
"remote": "https://drive.example.com/p/7f3a2c91-…",
"post_sync": "./.bdrive-notify.sh" }
```
The applied batch arrives as JSON on stdin, one object per cycle:
```json
{ "project": "m-5a10b713", "folder": "/Users/you/notes",
"changed": [ { "path": "shared/findings/eu-checkout.md", "op": "write",
"user": "Dana Kim", "note": "claude session 41f2" },
{ "path": "notes/retired.md", "op": "delete",
"user": "Sam Ito" } ] }
```
`user` is the signed-in account's name, falling back to the account email and
then to the device's git/OS identity — the same order `bdrive log` prints.
`note` carries the agent session when one wrote the change. Both are omitted
when unknown, so a script written before they existed keeps working.
A minimal recipe, using `jq`:
```sh
#!/bin/sh
# .bdrive-notify.sh — chmod +x this, and keep it out of sync with .bdriveignore
HOOK="https://hooks.slack.com/services/T0/B0/xxxx"
text=$(jq -r '
.changed
| .[0:20]
| map(
(.user // "Someone")
+ (if (.note // "") | test("^claude ") then "'"'"'s Claude" else "" end)
+ (if .op == "delete" then " deleted " else " updated " end)
+ .path
)
| join("\n")')
[ -n "$text" ] && curl -sf -X POST -H 'Content-type: application/json' \
--data "$(jq -n --arg t "$text" '{text:$t}')" "$HOOK" >/dev/null
```
Two things worth knowing before you turn it on:
- **The first cycle on a fresh folder materializes everything.** That is one
invocation carrying every path in the project — hence the `.[0:20]` cap
above. Without it, day one is a wall of several hundred lines.
- **It fires once per cycle**, inbound only. A cycle that just pushes your own
edits sends nothing, and a hook that hangs or exits non-zero is logged and
forgotten — it can never break sync.
`post_sync` lives in `.bdrive/config.json`, which never syncs. Nobody else can
put a command on your machine. See
[Project files](/reference/project-files/) for the full field reference.
## Option 2 — a project webhook on the hub
The device-side recipe only covers people running a daemon. If a teammate
works entirely in the browser — uploading files, restoring versions — their
writes never touch anyone's `post_sync` until a device syncs them. A project
webhook is the hub telling the channel directly.
A **project admin** opens **Settings → General → Change notifications**,
pastes the incoming-webhook URL, and saves. That is the whole surface: there
is no prompt, no nav entry and no empty-state card. A project with no webhook
set produces no notifications and makes no outbound requests at all.
What you get:
- One message per journal write, batching that write's changes into a single
post, capped at 20 lines plus an "…and N more".
- Every hub-side write covered — device syncs, browser uploads, removes,
restores and run undo.
- The message names the actor, in the same wording as above.
Some deliberate limits:
- **Only Slack and Teams hosts are accepted**: `hooks.slack.com`,
`*.webhook.office.com` and `*.logic.azure.com`, `https://` only. A URL the
hub fetches on an admin's say-so is a way to make the hub reach things it
should not, and the host check closes that. Mattermost, Discord and n8n are
not accepted today.
- **The URL never leaves the server.** Once saved, no API response ever
returns it — the settings page shows *On* and a **Turn off** button, and
pasting a new URL replaces the old one. It is a credential: whoever holds it
can post into your channel.
- **No retries.** A delivery that fails is logged once and dropped. This is a
notifier, not a queue.
- **No filtering or quiet window** in this version. A busy agent session on
the default ten-second sync interval is a handful of messages a minute per
device — worth knowing before you point it at a channel people read.
@@ -48,10 +48,30 @@ directory set to the folder:
```json
{ "project": "m-5a10b713", "folder": "/Users/you/notes",
"changed": [ { "path": "wiki/onboarding.md", "op": "write" },
{ "path": "notes/retired.md", "op": "delete" } ] }
"changed": [ { "path": "wiki/onboarding.md", "op": "write",
"user": "Dana Kim", "note": "claude session 41f2" },
{ "path": "notes/retired.md", "op": "delete",
"user": "Sam Ito" } ] }
```
Each changed entry carries:
- `path` — mount-relative, slash-separated.
- `op``write` or `delete`. Deletes carry the same fields as writes.
- `user` — who committed the change: the signed-in account's display name,
falling back to its email and then to the device's git/OS identity. That is
the same precedence `bdrive log` prints.
- `note` — the committing op's note. The agent sync hook stamps
`"<platform> session <id>"` (`claude`, `codex`, `gemini`, `hermes`), which is
what lets a recipe write *"Dana Kim's Claude updated …"*. **`note` is
user-settable** (`bdrive sync --note "…"`) **and display-only — it names an
agent, it never proves one.**
`user` and `note` are omitted when unknown, so a hook written against the
earlier `path`/`op` payload keeps working unchanged. For a worked Slack and
Teams recipe, see [Tell Slack what your agents
changed](/guides/slack-notifications/).
- **Once per cycle** that applied at least one path — an initial sync of 400
files is one invocation, not 400.
- **Inbound only.** A cycle that only commits and pushes your own edits fires