// Package secrets provides a local file-backed implementation of the // ports.SecretStore port. It is a Stage-0 stand-in for op/ESO: secret material // (the YouTube OAuth refresh token) is kept in a 0600 JSON file rather than the // vault, so the demo runs without live op. Because every consumer depends on // the SecretStore port, swapping this for an op/ESO-backed store later is a // wiring change, not a code change (ADR-002, docs/homelab-integration.md). // // Secret values are never logged. Get returns an error for an unknown ref so a // missing token surfaces loudly rather than as an empty string. package secrets import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "sync" "git.d-ma.be/mathias/tapir/internal/ports" ) // FileStore is a SecretStore backed by a single 0600 JSON file mapping opaque // refs to secret values. Safe for concurrent use within one process. type FileStore struct { path string mu sync.RWMutex } // Static check: FileStore satisfies the read side of the port. var _ ports.SecretStore = (*FileStore)(nil) // ErrNotFound is returned by Get when no secret is stored under the ref. var ErrNotFound = errors.New("secrets: ref not found") // NewFileStore returns a store backed by path. The file need not exist yet; it // is created on the first Put. func NewFileStore(path string) *FileStore { return &FileStore{path: path} } // Get resolves a ref to its secret value. It returns ErrNotFound if the file or // the ref is absent. func (s *FileStore) Get(_ context.Context, ref string) (string, error) { s.mu.RLock() defer s.mu.RUnlock() m, err := s.load() if err != nil { return "", err } v, ok := m[ref] if !ok { return "", fmt.Errorf("%w: %q", ErrNotFound, ref) } return v, nil } // Put stores value under ref, persisting the file with 0600 permissions. It // merges into any existing entries and writes atomically (temp file + rename). func (s *FileStore) Put(ref, value string) error { s.mu.Lock() defer s.mu.Unlock() m, err := s.load() if err != nil && !errors.Is(err, os.ErrNotExist) { return err } if m == nil { m = make(map[string]string) } m[ref] = value 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 } // 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) { b, err := os.ReadFile(s.path) if err != nil { if errors.Is(err, os.ErrNotExist) { return map[string]string{}, nil } return nil, fmt.Errorf("secrets: read %s: %w", s.path, err) } var m map[string]string if err := json.Unmarshal(b, &m); err != nil { return nil, fmt.Errorf("secrets: parse %s: %w", s.path, err) } return m, nil }