fix(server): party_source gate must not block writes to an established party

partySourceGate ran on every PUT/move at party-depth-or-below and rejected
with 409 whenever the party lacked a registry row — including edits of files
already filed under working/<party>/…. The gate is an ONBOARDING guard (don't
let a typo'd/unregistered party folder be introduced), not a write gate: once
the party directory exists on disk the party is established, so editing within
it must succeed. Allow when <project>/<peer>/<party>/ already exists; keep the
409 only for introducing a brand-new unregistered party.

This was surfaced by the browse markdown editor 409ing on save for an existing
file under a party folder whose ssr/ row was missing or differently-cased.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-08 07:59:44 -05:00
parent d5ce4e1230
commit 9320515214
2 changed files with 24 additions and 0 deletions

View file

@ -922,6 +922,16 @@ func partySourceGate(fsRoot, abs string) (reject bool, msg, party string) {
if source == "" {
return false, "", "" // peer does no party gating (e.g. ssr/)
}
// The gate only guards INTRODUCING a new party. Once the party
// directory exists on disk the party is established, so a PUT/move
// into its existing subtree (e.g. editing a file already filed under
// working/<party>/…) must not be blocked — the registration check is
// an onboarding guard, not a write gate. Without this, editing any
// pre-existing file under a party folder whose registry row is
// missing or differently-cased 409s on save.
if fi, err := os.Stat(filepath.Join(fsRoot, project, peer, p)); err == nil && fi.IsDir() {
return false, "", p
}
if zddc.PartyRegistered(filepath.Join(fsRoot, project), source, p) {
return false, "", p
}

View file

@ -854,6 +854,20 @@ func TestFileAPI_PartySourceGate(t *testing.T) {
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "Acme", "drafts")); err != nil {
t.Errorf("workspace folder not created: %v", err)
}
// Regression: a party folder that already exists on disk but whose
// registry row is missing (deleted, migrated, or differently-cased)
// must still accept writes. The gate guards INTRODUCING a party, not
// editing one that's established — without this, opening any
// pre-existing file under such a folder and saving it 409s. Create
// the folder out-of-band (no ssr/ row) and PUT into it.
if err := os.MkdirAll(filepath.Join(root, "Proj", "working", "Ghost"), 0o755); err != nil {
t.Fatal(err)
}
rec = do(http.MethodPut, "/Proj/working/Ghost/note.md", "alice@example.com", []byte("# hi\n"), nil)
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
t.Errorf("PUT into existing unregistered party folder: want 201/200, got %d: %s", rec.Code, rec.Body.String())
}
}
// An in-place rename of a markdown file carries its .history/<stem>/ folder