Skip to content

feat(webapp): old URLs follow a moved file, and share links follow the file (BEA-81) - #130

Open
ssowonny wants to merge 1 commit into
mainfrom
bea-81-support-canonical-urls-for-moved-files
Open

feat(webapp): old URLs follow a moved file, and share links follow the file (BEA-81)#130
ssowonny wants to merge 1 commit into
mainfrom
bea-81-support-canonical-urls-for-moved-files

Conversation

@ssowonny

@ssowonny ssowonny commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

TL;DR

  • Rename a file, or drag it into a folder, and every URL anyone had for it 404'd. Now the old URL lands on the file, rewrites itself, and says Moved from … above the content.
  • Quieter and worse: a share link whose file was deleted used to start serving whatever unrelated file later landed on that path — public, with no revoke and no signal. It now 404s, and stays 404.
  • Share links deliberately go the opposite way from viewer URLs: a share follows its file, a viewer URL is an address and a live path always wins.
  • History and restore follow the chain too, so a moved file can still reach — and restore — its own past versions.
  • Known gap: read heat stays split. New reads land on the canonical path; the old path's historical buckets are not rewritten.

The two rules point in opposite directions, on purpose

a.md moves to docs/a.md, and then a new a.md appears at the old address:

flowchart TB
    E["a.md -> docs/a.md,<br/>then a NEW a.md appears"]
    V["<div style='text-align:left'><b>/proj/a.md</b> (viewer)<br/>the NEW a.md wins<br/>no redirect: a live path<br/>always wins</div>"]
    S["<div style='text-align:left'><b>/s/&lt;token&gt;</b> (share)<br/>docs/a.md wins<br/>the link follows the file<br/>it was minted for</div>"]
    E -- "an address" --> V
    E -- "a promise about one file" --> S
    classDef box fill:#88888822,stroke:#888888
    class E,V,S box
Loading

A viewer URL is an address — whatever is at that address today is the right answer. A share token is a promise about one file, and must never resolve to a file it wasn't minted for.

There is no rename to read

internal/syncer's scan emits a put for the path it has never seen and a delete for the cache key it no longer sees — same cycle, same device, same blob. journal.Op has put and delete and nothing else. So a move is only ever inferred, and everything keyed on a path broke the moment a file moved.

New internal/webapp/moves.go derives the pairing inside the replay RemoteSource.Files already runs, and caches it with the snapshot. No new op kind, no new storage, no extra journal read.

A delete of A pairs with the first-ever put of B when all hold:

Condition Why
same Op.Device one device's scan produces both halves
|Δt| ≤ 30s wider than one daemon cycle, narrower than an editing session
B's blob is the blob A held just before its delete content identity is the only link the journal gives us
the put is B's first version ever a move creates a path, it doesn't overwrite one
one-to-one in both directions duplicated content and empty files produce many same-blob candidates

Ambiguous → no pairing, no redirect. Silence beats a wrong destination.

This is deliberately not a rename op. A rename op changes journal.Less / Replay, which is what every device converges to — and every already-shipped journal would still need the heuristic to read its own history. Derivation is reversible; a new op kind isn't.

Four call sites, one index

Surface Before Now
Viewer /file /render /download 404 no such file: a.md serves the destination + X-Bdrive-Canonical-Path: docs/a.md; the read is recorded against the canonical path
GET .../resolve?path= new, PermRead: {"to":…,"kind":"file"|"folder"} or 404
Share /s/<token> 404 — or silently served a successor follows the file; 404 forever after a non-move delete
history?path= the file's past dropped out of its own feed includes every path in the chain, each hop time-bounded
restore refused every version made before the move accepts a sha from any ancestor, writes the put at the current path

Folder redirects are derived from the file mappings (there are no folder ops) and are all-or-nothing: every file that was under notes/ must land on wiki/<same suffix>, nothing under notes/ still live, no non-move delete in the way. A partial match gets no folder redirect — the individual files still redirect on their own.

What can break

  • Shares behave differently now. A share whose file was deleted-and-not-moved 404s where it used to serve whatever later occupied the path. That is the security half of this PR, and it is the one behavior change beyond redirects. Documented in web/docs/.../guides/agent-artifacts.md.
  • Op.Time is unvalidated peer JSON, and both the 30s window and the sh.Created comparison read it. A member who can write a journal can shape a pairing — but they can already put arbitrary bytes at the shared path, so this is not an escalation. The requirement is only that a hostile Time cannot loop or panic: every walk carries a visited set and a hop cap, and TestResolveForwardCycle pins it.
  • The index rides in the cached snapshot, one entry per path ever moved or deleted. It grows with churn, not with file count. A ponytail: comment names the one O(n²) bucket (identical copies of a single file) and its upgrade path.
  • Nothing here writes an op or touches sync. journal.Less, Replay, scan, materialize and the CLI are untouched; TestRenameConvergesAsPutPlusDelete in internal/syncer is the test that keeps it that way.

Deviations from the reviewed plan

Named rather than smuggled:

  1. One PR, not four. The plan sliced this into index → SPA → shares → history/restore. The issue's acceptance criteria span all four, so shipping only PR 1 would have left the issue unmet. The slices are still visible as separate concerns in the diff.
  2. restore.go now sorts before building the index. loadOps returns unsorted and buildMoveIndex needs journal.Less order.
  3. The e2e seed gained a moved file (old-guide.mdarchive/moved-guide.md), because the redirect has no browser-level proof without one.
  4. BEA-64 is still unbuilt and edits the same history?path= filter this PR rewrites. It is the older issue; expect a textual conflict there, not a semantic one.

What was run

  • go test ./... — passes, including 22 new cases in internal/webapp/moves_test.go, shares_test.go, restore_test.go and the multi-device convergence guard in internal/syncer/syncer_test.go.
  • npm run e2e — 155 passed, 1 skipped, including two new specs: the moved file's old URL redirects and banners, and a genuinely unknown path still gets the not-found card.
  • npm run build + frontend/check-dist.shinternal/webapp/static is committed and fresh.
  • UI driven for real at 1280×800 against the seeded hub; screenshots below.

Screenshots

Before (origin/main) After
before after

A path that never existed is untouched — still the not-found card, no banner, no redirect:

unknown path

Architecture changes

architecture/webapp-server.md

MoveSource joins Uploader as an optional capability on Source*RemoteSource implements it, DirSource doesn't, so the "no journals, no moves" exclusion falls out of the type assertion instead of needing a rule. volume's cached snapshot now carries the derived moveIndex alongside its file listing, and three new value types hang off it: pathEvent (one moment a path stopped being its file), and segment (the time window during which a path was the file being asked about).

✅ added · ❌ removed (strikethrough) · unmarked = unchanged

flowchart TB
    Source["<div style='text-align:left'><b>Source</b> «interface»<br/>+Files(ctx) map path→FileInfo<br/>+Open(ctx, path, fi) io.ReadCloser</div>"]
    DirSource["<div style='text-align:left'><b>DirSource</b><br/>+Dir string</div>"]
    RemoteSource["<div style='text-align:left'><b>RemoteSource</b><br/>+Backend remote.Backend<br/>+Device Identity<br/>-loadSourcedOps(ctx) []sourcedOp<br/>-appendOp(ctx, op)</div>"]
    volume["<div style='text-align:left'><b>volume</b><br/>-source Source<br/>-refresh time.Duration<br/><span style='background:#ef444455;padding:0 4px;border-radius:3px'>❌ <s>-snap *snapshot</s></span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -snap *snapshot (files + moves)</span><br/>+snapshot(ctx)<br/>+invalidate()</div>"]
    MoveSource["<div style='text-align:left'><b>MoveSource</b> «interface»<br/>+FilesWithMoves(ctx) files, moveIndex</div>"]
    moveIndex["<div style='text-align:left'><b>moveIndex</b> «map path→[]pathEvent»<br/>+buildMoveIndex(sorted ops)<br/>+resolveForward(idx, files, p) viewer<br/>+resolveShare(idx, files, p, since) /s/<br/>+chainSegments(idx, p) []segment<br/>+resolveFolder(idx, files, dir)</div>"]
    pathEvent["<div style='text-align:left'><b>pathEvent</b><br/>+At the delete that ended it<br/>+To &quot;&quot; = deleted, not moved<br/>+ToAt destination's create</div>"]
    segment["<div style='text-align:left'><b>segment</b><br/>+Path<br/>+From, To window it WAS the file</div>"]
    Note["Derived in the replay Files already runs,<br/>cached with the snapshot.<br/>No new op kind, no extra journal read.<br/>journal.Less and Replay are untouched."]
    volume -- "source" --> Source
    DirSource -. implements .-> Source
    RemoteSource -. implements .-> Source
    RemoteSource -. "✅ implements" .-> MoveSource
    MoveSource -- "✅ returns" --> moveIndex
    volume -- "✅ cached with the snapshot" --> moveIndex
    moveIndex -- "✅ per path, in time order" --> pathEvent
    moveIndex -- "✅ chainSegments" --> segment
    moveIndex -.- Note
    classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px
    classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2
    class MoveSource,moveIndex,pathEvent,segment added
    class Note noteBox
    linkStyle 3 stroke:#22c55e,stroke-width:2px
    linkStyle 4 stroke:#22c55e,stroke-width:2px
    linkStyle 5 stroke:#22c55e,stroke-width:2px
    linkStyle 6 stroke:#22c55e,stroke-width:2px
    linkStyle 7 stroke:#22c55e,stroke-width:2px
Loading

architecture/webapp-frontend.md

Browser gained one member and no new relationships. It decides a path is missing from /tree alone and never fetches the file, so the X-Bdrive-Canonical-Path header would never have reached the browser — and a moved folder has no content fetch to hang a header on. The not-found branch asks /resolve?path= instead, on the miss path only.

✅ added · ❌ removed (strikethrough) · unmarked = unchanged

flowchart TB
    HubApp["HubApp"]
    VolumeApp["VolumeApp"]
    Browser["<div style='text-align:left'><b>Browser</b><br/>folder listing, file view<br/>per-view routes<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +moved: /resolve?path= on a tree miss only</span></div>"]
    nav["<div style='text-align:left'><b>nav</b><br/>+navigate(url, {replace})</div>"]
    Note["The happy path is unchanged:<br/>no /resolve request fires<br/>while /tree has the path."]
    HubApp --> Browser
    VolumeApp --> Browser
    Browser -- "replaceState to the destination" --> nav
    Browser -.- Note
    classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2
    class Note noteBox
Loading

The one number worth revisiting

The 30s / same-device / same-blob window is a heuristic chosen to be wider than one daemon cycle and narrower than a person's editing session. It has never met a real journal. If moves start declining to pair on a busy team's volume, that constant — moveWindow in moves.go — is where to look first.

Build session

cd $(git worktree list | grep bea-81 | awk '{print $1}') && claude --resume 7a505ce0-aaf7-4523-bbf3-ba0ea7c3765b

(only works on the machine this ran on)

…e file (BEA-81)

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>
@ssowonny
ssowonny requested a review from thefron August 5, 2026 13:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant