From 17d5e8c39347d9680c47cda8fefd3ce5306fd179 Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 3 Jun 2026 16:50:20 +0200 Subject: [PATCH] feat(secrets): FileStore.Delete to purge a user's OAuth tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Account disconnect/delete needs to remove the per-user YouTube refresh token from the SecretStore. Add Delete(ref) on the file-backed store, mirroring Put: atomic temp-file+rename, 0600, no-op on an absent ref. Kept off the read-only ports.SecretStore (Get) — write/delete follow the existing auth.TokenWriter convention of narrow capability interfaces, so the youtube adapter's read-only dependency is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/adapters/secrets/file.go | 37 ++++++++++++++++++++++++++ internal/adapters/secrets/file_test.go | 34 +++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/internal/adapters/secrets/file.go b/internal/adapters/secrets/file.go index 1b90124..3d97422 100644 --- a/internal/adapters/secrets/file.go +++ b/internal/adapters/secrets/file.go @@ -89,6 +89,43 @@ func (s *FileStore) Put(ref, value string) error { return nil } +// Delete removes the secret stored under ref, persisting the file atomically +// (temp file + rename) with 0600 permissions. Deleting an absent ref — or one in +// a file that does not exist yet — is a no-op, not an error. Used by account +// management (disconnect / delete-account) to purge a user's OAuth tokens. +func (s *FileStore) Delete(ref string) error { + s.mu.Lock() + defer s.mu.Unlock() + + m, err := s.load() + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil // nothing to delete + } + return err + } + if _, ok := m[ref]; !ok { + return nil // already absent + } + delete(m, ref) + + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return fmt.Errorf("secrets: create dir: %w", err) + } + b, err := json.Marshal(m) + if err != nil { + return fmt.Errorf("secrets: marshal: %w", err) + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return fmt.Errorf("secrets: write temp: %w", err) + } + if err := os.Rename(tmp, s.path); err != nil { + return fmt.Errorf("secrets: rename: %w", err) + } + return nil +} + // load reads the backing file. A missing file yields an empty map (not an // error) for Get's caller, except Put distinguishes os.ErrNotExist. func (s *FileStore) load() (map[string]string, error) { diff --git a/internal/adapters/secrets/file_test.go b/internal/adapters/secrets/file_test.go index 1233383..7e71431 100644 --- a/internal/adapters/secrets/file_test.go +++ b/internal/adapters/secrets/file_test.go @@ -49,6 +49,40 @@ func TestPutIsOwnerOnly(t *testing.T) { } } +func TestDeleteRemovesRefAndLeavesOthers(t *testing.T) { + path := filepath.Join(t.TempDir(), "secrets.json") + s := secrets.NewFileStore(path) + if err := s.Put("youtube/u1/refresh_token", "rt-1"); err != nil { + t.Fatalf("Put: %v", err) + } + if err := s.Put("youtube/u2/refresh_token", "rt-2"); err != nil { + t.Fatalf("Put: %v", err) + } + + if err := s.Delete("youtube/u1/refresh_token"); err != nil { + t.Fatalf("Delete: %v", err) + } + + // The deleted ref is gone (persisted: re-open from disk)... + s2 := secrets.NewFileStore(path) + if _, err := s2.Get(context.Background(), "youtube/u1/refresh_token"); !errors.Is(err, secrets.ErrNotFound) { + t.Errorf("Get deleted ref: err = %v, want ErrNotFound", err) + } + // ...and the other user's secret survives. + if got, err := s2.Get(context.Background(), "youtube/u2/refresh_token"); err != nil || got != "rt-2" { + t.Errorf("Get surviving ref = (%q, %v), want (%q, nil)", got, err, "rt-2") + } +} + +func TestDeleteAbsentRefIsNoop(t *testing.T) { + // Deleting an unknown ref — or from a file that does not exist yet — is a + // no-op, not an error (mirrors store.DeleteConnection semantics). + s := secrets.NewFileStore(filepath.Join(t.TempDir(), "secrets.json")) + if err := s.Delete("missing"); err != nil { + t.Errorf("Delete absent ref: %v, want nil", err) + } +} + func TestPutMergesEntries(t *testing.T) { path := filepath.Join(t.TempDir(), "secrets.json") s := secrets.NewFileStore(path)