diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index f46843c..bca4abe 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -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//…) 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 } diff --git a/zddc/internal/handler/fileapi_test.go b/zddc/internal/handler/fileapi_test.go index 3023a0e..92a6140 100644 --- a/zddc/internal/handler/fileapi_test.go +++ b/zddc/internal/handler/fileapi_test.go @@ -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// folder