···2626 onAuthSuccess func(did string) // Callback when user authenticates successfully
2727}
28282929-// NewOAuthManager creates a new OAuth manager with the given configuration
3030-func NewOAuthManager(clientID, redirectURI string) (*OAuthManager, error) {
2929+// NewOAuthManager creates a new OAuth manager with the given configuration.
3030+// If store is nil, an in-memory store will be used (sessions lost on restart).
3131+// For production, pass a persistent store (e.g., boltstore.SessionStore).
3232+func NewOAuthManager(clientID, redirectURI string, store oauth.ClientAuthStore) (*OAuthManager, error) {
3133 var config oauth.ClientConfig
32343335 // Check if we should use localhost config
···3941 config = oauth.NewPublicConfig(clientID, redirectURI, scopes)
4042 }
41434242- // Use in-memory store for development
4343- // TODO(production): Replace with persistent store (e.g., SQLite-backed oauth.Store implementation)
4444- // The in-memory store will lose all sessions on server restart, requiring users to re-authenticate.
4545- // For production, implement oauth.Store interface with persistent storage.
4646- store := oauth.NewMemStore()
4444+ // Use provided store, or fall back to in-memory for development
4545+ if store == nil {
4646+ store = oauth.NewMemStore()
4747+ }
47484849 // Create the OAuth client app
4950 app := oauth.NewClientApp(&config, store)
+157
internal/database/boltstore/feed_store.go
···11+package boltstore
22+33+import (
44+ "encoding/json"
55+ "time"
66+77+ bolt "go.etcd.io/bbolt"
88+)
99+1010+// FeedUser represents a registered user in the feed registry.
1111+type FeedUser struct {
1212+ DID string `json:"did"`
1313+ RegisteredAt time.Time `json:"registered_at"`
1414+}
1515+1616+// FeedStore provides persistent storage for the feed registry.
1717+// It stores DIDs of users who have logged in and should appear in the community feed.
1818+type FeedStore struct {
1919+ db *bolt.DB
2020+}
2121+2222+// Register adds a DID to the feed registry.
2323+// If the DID already exists, this is a no-op.
2424+func (s *FeedStore) Register(did string) error {
2525+ return s.db.Update(func(tx *bolt.Tx) error {
2626+ bucket := tx.Bucket(BucketFeedRegistry)
2727+ if bucket == nil {
2828+ return nil
2929+ }
3030+3131+ // Check if already registered
3232+ existing := bucket.Get([]byte(did))
3333+ if existing != nil {
3434+ // Already registered, no-op
3535+ return nil
3636+ }
3737+3838+ // Create new registration
3939+ user := FeedUser{
4040+ DID: did,
4141+ RegisteredAt: time.Now(),
4242+ }
4343+4444+ data, err := json.Marshal(user)
4545+ if err != nil {
4646+ return err
4747+ }
4848+4949+ return bucket.Put([]byte(did), data)
5050+ })
5151+}
5252+5353+// Unregister removes a DID from the feed registry.
5454+func (s *FeedStore) Unregister(did string) error {
5555+ return s.db.Update(func(tx *bolt.Tx) error {
5656+ bucket := tx.Bucket(BucketFeedRegistry)
5757+ if bucket == nil {
5858+ return nil
5959+ }
6060+6161+ return bucket.Delete([]byte(did))
6262+ })
6363+}
6464+6565+// IsRegistered checks if a DID is in the feed registry.
6666+func (s *FeedStore) IsRegistered(did string) bool {
6767+ var registered bool
6868+6969+ s.db.View(func(tx *bolt.Tx) error {
7070+ bucket := tx.Bucket(BucketFeedRegistry)
7171+ if bucket == nil {
7272+ return nil
7373+ }
7474+7575+ registered = bucket.Get([]byte(did)) != nil
7676+ return nil
7777+ })
7878+7979+ return registered
8080+}
8181+8282+// List returns all registered DIDs.
8383+func (s *FeedStore) List() []string {
8484+ var dids []string
8585+8686+ s.db.View(func(tx *bolt.Tx) error {
8787+ bucket := tx.Bucket(BucketFeedRegistry)
8888+ if bucket == nil {
8989+ return nil
9090+ }
9191+9292+ return bucket.ForEach(func(k, v []byte) error {
9393+ dids = append(dids, string(k))
9494+ return nil
9595+ })
9696+ })
9797+9898+ return dids
9999+}
100100+101101+// ListWithMetadata returns all registered users with their metadata.
102102+func (s *FeedStore) ListWithMetadata() []FeedUser {
103103+ var users []FeedUser
104104+105105+ s.db.View(func(tx *bolt.Tx) error {
106106+ bucket := tx.Bucket(BucketFeedRegistry)
107107+ if bucket == nil {
108108+ return nil
109109+ }
110110+111111+ return bucket.ForEach(func(k, v []byte) error {
112112+ var user FeedUser
113113+ if err := json.Unmarshal(v, &user); err != nil {
114114+ // Fallback for simple keys without metadata
115115+ user = FeedUser{DID: string(k)}
116116+ }
117117+ users = append(users, user)
118118+ return nil
119119+ })
120120+ })
121121+122122+ return users
123123+}
124124+125125+// Count returns the number of registered users.
126126+func (s *FeedStore) Count() int {
127127+ var count int
128128+129129+ s.db.View(func(tx *bolt.Tx) error {
130130+ bucket := tx.Bucket(BucketFeedRegistry)
131131+ if bucket == nil {
132132+ return nil
133133+ }
134134+135135+ count = bucket.Stats().KeyN
136136+ return nil
137137+ })
138138+139139+ return count
140140+}
141141+142142+// Clear removes all entries from the feed registry.
143143+// Use with caution - primarily for testing.
144144+func (s *FeedStore) Clear() error {
145145+ return s.db.Update(func(tx *bolt.Tx) error {
146146+ // Delete and recreate the bucket
147147+ if err := tx.DeleteBucket(BucketFeedRegistry); err != nil {
148148+ // Bucket might not exist, that's ok
149149+ if err != bolt.ErrBucketNotFound {
150150+ return err
151151+ }
152152+ }
153153+154154+ _, err := tx.CreateBucket(BucketFeedRegistry)
155155+ return err
156156+ })
157157+}
+207
internal/database/boltstore/session_store.go
···11+package boltstore
22+33+import (
44+ "context"
55+ "encoding/json"
66+ "fmt"
77+88+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
99+ "github.com/bluesky-social/indigo/atproto/syntax"
1010+ bolt "go.etcd.io/bbolt"
1111+)
1212+1313+// SessionStore implements oauth.ClientAuthStore using BoltDB for persistence.
1414+// It stores OAuth sessions and auth request data, allowing sessions to survive
1515+// server restarts.
1616+type SessionStore struct {
1717+ db *bolt.DB
1818+}
1919+2020+// Ensure SessionStore implements oauth.ClientAuthStore
2121+var _ oauth.ClientAuthStore = (*SessionStore)(nil)
2222+2323+// sessionKey generates a composite key for session storage: "did:sessionID"
2424+func sessionKey(did syntax.DID, sessionID string) []byte {
2525+ return []byte(did.String() + ":" + sessionID)
2626+}
2727+2828+// GetSession retrieves a session by DID and session ID.
2929+// Returns an error if the session is not found.
3030+func (s *SessionStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
3131+ var session oauth.ClientSessionData
3232+3333+ err := s.db.View(func(tx *bolt.Tx) error {
3434+ bucket := tx.Bucket(BucketSessions)
3535+ if bucket == nil {
3636+ return fmt.Errorf("session bucket not found")
3737+ }
3838+3939+ data := bucket.Get(sessionKey(did, sessionID))
4040+ if data == nil {
4141+ return fmt.Errorf("session not found")
4242+ }
4343+4444+ return json.Unmarshal(data, &session)
4545+ })
4646+4747+ if err != nil {
4848+ return nil, err
4949+ }
5050+5151+ return &session, nil
5252+}
5353+5454+// SaveSession persists a session (upsert operation).
5555+// If a session with the same DID and sessionID exists, it will be updated.
5656+func (s *SessionStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
5757+ data, err := json.Marshal(sess)
5858+ if err != nil {
5959+ return fmt.Errorf("failed to marshal session: %w", err)
6060+ }
6161+6262+ return s.db.Update(func(tx *bolt.Tx) error {
6363+ bucket := tx.Bucket(BucketSessions)
6464+ if bucket == nil {
6565+ return fmt.Errorf("session bucket not found")
6666+ }
6767+6868+ return bucket.Put(sessionKey(sess.AccountDID, sess.SessionID), data)
6969+ })
7070+}
7171+7272+// DeleteSession removes a session by DID and session ID.
7373+func (s *SessionStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
7474+ return s.db.Update(func(tx *bolt.Tx) error {
7575+ bucket := tx.Bucket(BucketSessions)
7676+ if bucket == nil {
7777+ return fmt.Errorf("session bucket not found")
7878+ }
7979+8080+ return bucket.Delete(sessionKey(did, sessionID))
8181+ })
8282+}
8383+8484+// GetAuthRequestInfo retrieves pending auth request data by state token.
8585+func (s *SessionStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
8686+ var info oauth.AuthRequestData
8787+8888+ err := s.db.View(func(tx *bolt.Tx) error {
8989+ bucket := tx.Bucket(BucketAuthRequests)
9090+ if bucket == nil {
9191+ return fmt.Errorf("auth requests bucket not found")
9292+ }
9393+9494+ data := bucket.Get([]byte(state))
9595+ if data == nil {
9696+ return fmt.Errorf("auth request not found")
9797+ }
9898+9999+ return json.Unmarshal(data, &info)
100100+ })
101101+102102+ if err != nil {
103103+ return nil, err
104104+ }
105105+106106+ return &info, nil
107107+}
108108+109109+// SaveAuthRequestInfo stores auth request data keyed by state token.
110110+// This is a create-only operation per the oauth.ClientAuthStore contract.
111111+func (s *SessionStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
112112+ data, err := json.Marshal(info)
113113+ if err != nil {
114114+ return fmt.Errorf("failed to marshal auth request: %w", err)
115115+ }
116116+117117+ return s.db.Update(func(tx *bolt.Tx) error {
118118+ bucket := tx.Bucket(BucketAuthRequests)
119119+ if bucket == nil {
120120+ return fmt.Errorf("auth requests bucket not found")
121121+ }
122122+123123+ return bucket.Put([]byte(info.State), data)
124124+ })
125125+}
126126+127127+// DeleteAuthRequestInfo removes auth request data by state token.
128128+func (s *SessionStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
129129+ return s.db.Update(func(tx *bolt.Tx) error {
130130+ bucket := tx.Bucket(BucketAuthRequests)
131131+ if bucket == nil {
132132+ return fmt.Errorf("auth requests bucket not found")
133133+ }
134134+135135+ return bucket.Delete([]byte(state))
136136+ })
137137+}
138138+139139+// ListSessions returns all sessions (for debugging/admin purposes).
140140+func (s *SessionStore) ListSessions(ctx context.Context) ([]oauth.ClientSessionData, error) {
141141+ var sessions []oauth.ClientSessionData
142142+143143+ err := s.db.View(func(tx *bolt.Tx) error {
144144+ bucket := tx.Bucket(BucketSessions)
145145+ if bucket == nil {
146146+ return nil
147147+ }
148148+149149+ return bucket.ForEach(func(k, v []byte) error {
150150+ var session oauth.ClientSessionData
151151+ if err := json.Unmarshal(v, &session); err != nil {
152152+ // Skip malformed entries
153153+ return nil
154154+ }
155155+ sessions = append(sessions, session)
156156+ return nil
157157+ })
158158+ })
159159+160160+ return sessions, err
161161+}
162162+163163+// CountSessions returns the number of stored sessions.
164164+func (s *SessionStore) CountSessions(ctx context.Context) (int, error) {
165165+ var count int
166166+167167+ err := s.db.View(func(tx *bolt.Tx) error {
168168+ bucket := tx.Bucket(BucketSessions)
169169+ if bucket == nil {
170170+ return nil
171171+ }
172172+173173+ count = bucket.Stats().KeyN
174174+ return nil
175175+ })
176176+177177+ return count, err
178178+}
179179+180180+// DeleteAllSessionsForDID removes all sessions for a given DID.
181181+// Useful for "logout from all devices" functionality.
182182+func (s *SessionStore) DeleteAllSessionsForDID(ctx context.Context, did syntax.DID) error {
183183+ prefix := []byte(did.String() + ":")
184184+185185+ return s.db.Update(func(tx *bolt.Tx) error {
186186+ bucket := tx.Bucket(BucketSessions)
187187+ if bucket == nil {
188188+ return nil
189189+ }
190190+191191+ // Collect keys to delete (can't delete while iterating)
192192+ var keysToDelete [][]byte
193193+ c := bucket.Cursor()
194194+ for k, _ := c.Seek(prefix); k != nil && len(k) >= len(prefix) && string(k[:len(prefix)]) == string(prefix); k, _ = c.Next() {
195195+ keysToDelete = append(keysToDelete, append([]byte{}, k...))
196196+ }
197197+198198+ // Delete collected keys
199199+ for _, k := range keysToDelete {
200200+ if err := bucket.Delete(k); err != nil {
201201+ return err
202202+ }
203203+ }
204204+205205+ return nil
206206+ })
207207+}
+135
internal/database/boltstore/store.go
···11+// Package boltstore provides persistent storage using BoltDB (bbolt).
22+// It implements the oauth.ClientAuthStore interface for session persistence
33+// and provides storage for the feed registry.
44+package boltstore
55+66+import (
77+ "fmt"
88+ "os"
99+ "path/filepath"
1010+ "time"
1111+1212+ bolt "go.etcd.io/bbolt"
1313+)
1414+1515+// Bucket names for organizing data
1616+var (
1717+ // BucketSessions stores OAuth session data keyed by "did:sessionID"
1818+ BucketSessions = []byte("oauth_sessions")
1919+2020+ // BucketAuthRequests stores pending OAuth auth requests keyed by state
2121+ BucketAuthRequests = []byte("oauth_auth_requests")
2222+2323+ // BucketFeedRegistry stores registered user DIDs for the community feed
2424+ BucketFeedRegistry = []byte("feed_registry")
2525+)
2626+2727+// Store wraps a BoltDB database and provides access to specialized stores.
2828+type Store struct {
2929+ db *bolt.DB
3030+}
3131+3232+// Options configures the BoltDB store.
3333+type Options struct {
3434+ // Path to the database file. Parent directories will be created if needed.
3535+ Path string
3636+3737+ // Timeout for obtaining a file lock on the database.
3838+ // If zero, a default of 5 seconds is used.
3939+ Timeout time.Duration
4040+4141+ // FileMode for creating the database file.
4242+ // If zero, 0600 is used.
4343+ FileMode os.FileMode
4444+}
4545+4646+// DefaultOptions returns sensible defaults for development.
4747+func DefaultOptions() Options {
4848+ return Options{
4949+ Path: "arabica.db",
5050+ Timeout: 5 * time.Second,
5151+ FileMode: 0600,
5252+ }
5353+}
5454+5555+// Open creates or opens a BoltDB database at the specified path.
5656+// It creates all necessary buckets if they don't exist.
5757+func Open(opts Options) (*Store, error) {
5858+ if opts.Path == "" {
5959+ opts.Path = "arabica.db"
6060+ }
6161+ if opts.Timeout == 0 {
6262+ opts.Timeout = 5 * time.Second
6363+ }
6464+ if opts.FileMode == 0 {
6565+ opts.FileMode = 0600
6666+ }
6767+6868+ // Ensure parent directory exists
6969+ dir := filepath.Dir(opts.Path)
7070+ if dir != "" && dir != "." {
7171+ if err := os.MkdirAll(dir, 0755); err != nil {
7272+ return nil, fmt.Errorf("failed to create database directory: %w", err)
7373+ }
7474+ }
7575+7676+ // Open the database
7777+ db, err := bolt.Open(opts.Path, opts.FileMode, &bolt.Options{
7878+ Timeout: opts.Timeout,
7979+ })
8080+ if err != nil {
8181+ return nil, fmt.Errorf("failed to open database: %w", err)
8282+ }
8383+8484+ // Create buckets if they don't exist
8585+ err = db.Update(func(tx *bolt.Tx) error {
8686+ buckets := [][]byte{
8787+ BucketSessions,
8888+ BucketAuthRequests,
8989+ BucketFeedRegistry,
9090+ }
9191+9292+ for _, bucket := range buckets {
9393+ _, err := tx.CreateBucketIfNotExists(bucket)
9494+ if err != nil {
9595+ return fmt.Errorf("failed to create bucket %s: %w", bucket, err)
9696+ }
9797+ }
9898+9999+ return nil
100100+ })
101101+ if err != nil {
102102+ db.Close()
103103+ return nil, err
104104+ }
105105+106106+ return &Store{db: db}, nil
107107+}
108108+109109+// Close closes the database.
110110+func (s *Store) Close() error {
111111+ if s.db != nil {
112112+ return s.db.Close()
113113+ }
114114+ return nil
115115+}
116116+117117+// DB returns the underlying BoltDB instance for advanced operations.
118118+func (s *Store) DB() *bolt.DB {
119119+ return s.db
120120+}
121121+122122+// SessionStore returns an OAuth session store backed by this database.
123123+func (s *Store) SessionStore() *SessionStore {
124124+ return &SessionStore{db: s.db}
125125+}
126126+127127+// FeedStore returns a feed registry store backed by this database.
128128+func (s *Store) FeedStore() *FeedStore {
129129+ return &FeedStore{db: s.db}
130130+}
131131+132132+// Stats returns database statistics.
133133+func (s *Store) Stats() bolt.Stats {
134134+ return s.db.Stats()
135135+}
+55-10
internal/feed/registry.go
···44 "sync"
55)
6677+// PersistentStore defines the interface for persistent feed registry storage.
88+// This allows the Registry to optionally persist DIDs to a database.
99+type PersistentStore interface {
1010+ Register(did string) error
1111+ Unregister(did string) error
1212+ IsRegistered(did string) bool
1313+ List() []string
1414+ Count() int
1515+}
1616+717// Registry tracks known Arabica users (DIDs) for the social feed.
88-// This is an in-memory store that gets populated as users log in.
99-// In the future, this could be persisted to a database.
1818+// It maintains an in-memory cache for fast access and optionally
1919+// persists data to a backing store.
1020type Registry struct {
1111- mu sync.RWMutex
1212- dids map[string]struct{}
2121+ mu sync.RWMutex
2222+ dids map[string]struct{}
2323+ store PersistentStore // Optional persistent backing store
1324}
14251515-// NewRegistry creates a new user registry
2626+// NewRegistry creates a new user registry with in-memory storage only.
1627func NewRegistry() *Registry {
1728 return &Registry{
1829 dids: make(map[string]struct{}),
1930 }
2031}
21322222-// Register adds a DID to the registry
3333+// NewPersistentRegistry creates a new registry backed by persistent storage.
3434+// It loads existing registrations from the store on creation.
3535+func NewPersistentRegistry(store PersistentStore) *Registry {
3636+ r := &Registry{
3737+ dids: make(map[string]struct{}),
3838+ store: store,
3939+ }
4040+4141+ // Load existing registrations from store
4242+ if store != nil {
4343+ for _, did := range store.List() {
4444+ r.dids[did] = struct{}{}
4545+ }
4646+ }
4747+4848+ return r
4949+}
5050+5151+// Register adds a DID to the registry.
5252+// If a persistent store is configured, the DID is also saved to the store.
2353func (r *Registry) Register(did string) {
2454 r.mu.Lock()
2555 defer r.mu.Unlock()
5656+5757+ // Add to in-memory cache
2658 r.dids[did] = struct{}{}
5959+6060+ // Persist if store is configured
6161+ if r.store != nil {
6262+ // Ignore errors for now - the in-memory cache is the source of truth
6363+ // during this session, and we'll retry on next restart
6464+ _ = r.store.Register(did)
6565+ }
2766}
28672929-// Unregister removes a DID from the registry
6868+// Unregister removes a DID from the registry.
6969+// If a persistent store is configured, the DID is also removed from the store.
3070func (r *Registry) Unregister(did string) {
3171 r.mu.Lock()
3272 defer r.mu.Unlock()
7373+3374 delete(r.dids, did)
7575+7676+ if r.store != nil {
7777+ _ = r.store.Unregister(did)
7878+ }
3479}
35803636-// IsRegistered checks if a DID is in the registry
8181+// IsRegistered checks if a DID is in the registry.
3782func (r *Registry) IsRegistered(did string) bool {
3883 r.mu.RLock()
3984 defer r.mu.RUnlock()
···4186 return ok
4287}
43884444-// List returns all registered DIDs
8989+// List returns all registered DIDs.
4590func (r *Registry) List() []string {
4691 r.mu.RLock()
4792 defer r.mu.RUnlock()
···5398 return dids
5499}
551005656-// Count returns the number of registered users
101101+// Count returns the number of registered users.
57102func (r *Registry) Count() int {
58103 r.mu.RLock()
59104 defer r.mu.RUnlock()