···26 onAuthSuccess func(did string) // Callback when user authenticates successfully
27}
2829-// NewOAuthManager creates a new OAuth manager with the given configuration
30-func NewOAuthManager(clientID, redirectURI string) (*OAuthManager, error) {
0031 var config oauth.ClientConfig
3233 // Check if we should use localhost config
···39 config = oauth.NewPublicConfig(clientID, redirectURI, scopes)
40 }
4142- // Use in-memory store for development
43- // TODO(production): Replace with persistent store (e.g., SQLite-backed oauth.Store implementation)
44- // The in-memory store will lose all sessions on server restart, requiring users to re-authenticate.
45- // For production, implement oauth.Store interface with persistent storage.
46- store := oauth.NewMemStore()
4748 // Create the OAuth client app
49 app := oauth.NewClientApp(&config, store)
···26 onAuthSuccess func(did string) // Callback when user authenticates successfully
27}
2829+// NewOAuthManager creates a new OAuth manager with the given configuration.
30+// If store is nil, an in-memory store will be used (sessions lost on restart).
31+// For production, pass a persistent store (e.g., boltstore.SessionStore).
32+func NewOAuthManager(clientID, redirectURI string, store oauth.ClientAuthStore) (*OAuthManager, error) {
33 var config oauth.ClientConfig
3435 // Check if we should use localhost config
···41 config = oauth.NewPublicConfig(clientID, redirectURI, scopes)
42 }
4344+ // Use provided store, or fall back to in-memory for development
45+ if store == nil {
46+ store = oauth.NewMemStore()
47+ }
04849 // Create the OAuth client app
50 app := oauth.NewClientApp(&config, store)
···1+// Package boltstore provides persistent storage using BoltDB (bbolt).
2+// It implements the oauth.ClientAuthStore interface for session persistence
3+// and provides storage for the feed registry.
4+package boltstore
5+6+import (
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "time"
11+12+ bolt "go.etcd.io/bbolt"
13+)
14+15+// Bucket names for organizing data
16+var (
17+ // BucketSessions stores OAuth session data keyed by "did:sessionID"
18+ BucketSessions = []byte("oauth_sessions")
19+20+ // BucketAuthRequests stores pending OAuth auth requests keyed by state
21+ BucketAuthRequests = []byte("oauth_auth_requests")
22+23+ // BucketFeedRegistry stores registered user DIDs for the community feed
24+ BucketFeedRegistry = []byte("feed_registry")
25+)
26+27+// Store wraps a BoltDB database and provides access to specialized stores.
28+type Store struct {
29+ db *bolt.DB
30+}
31+32+// Options configures the BoltDB store.
33+type Options struct {
34+ // Path to the database file. Parent directories will be created if needed.
35+ Path string
36+37+ // Timeout for obtaining a file lock on the database.
38+ // If zero, a default of 5 seconds is used.
39+ Timeout time.Duration
40+41+ // FileMode for creating the database file.
42+ // If zero, 0600 is used.
43+ FileMode os.FileMode
44+}
45+46+// DefaultOptions returns sensible defaults for development.
47+func DefaultOptions() Options {
48+ return Options{
49+ Path: "arabica.db",
50+ Timeout: 5 * time.Second,
51+ FileMode: 0600,
52+ }
53+}
54+55+// Open creates or opens a BoltDB database at the specified path.
56+// It creates all necessary buckets if they don't exist.
57+func Open(opts Options) (*Store, error) {
58+ if opts.Path == "" {
59+ opts.Path = "arabica.db"
60+ }
61+ if opts.Timeout == 0 {
62+ opts.Timeout = 5 * time.Second
63+ }
64+ if opts.FileMode == 0 {
65+ opts.FileMode = 0600
66+ }
67+68+ // Ensure parent directory exists
69+ dir := filepath.Dir(opts.Path)
70+ if dir != "" && dir != "." {
71+ if err := os.MkdirAll(dir, 0755); err != nil {
72+ return nil, fmt.Errorf("failed to create database directory: %w", err)
73+ }
74+ }
75+76+ // Open the database
77+ db, err := bolt.Open(opts.Path, opts.FileMode, &bolt.Options{
78+ Timeout: opts.Timeout,
79+ })
80+ if err != nil {
81+ return nil, fmt.Errorf("failed to open database: %w", err)
82+ }
83+84+ // Create buckets if they don't exist
85+ err = db.Update(func(tx *bolt.Tx) error {
86+ buckets := [][]byte{
87+ BucketSessions,
88+ BucketAuthRequests,
89+ BucketFeedRegistry,
90+ }
91+92+ for _, bucket := range buckets {
93+ _, err := tx.CreateBucketIfNotExists(bucket)
94+ if err != nil {
95+ return fmt.Errorf("failed to create bucket %s: %w", bucket, err)
96+ }
97+ }
98+99+ return nil
100+ })
101+ if err != nil {
102+ db.Close()
103+ return nil, err
104+ }
105+106+ return &Store{db: db}, nil
107+}
108+109+// Close closes the database.
110+func (s *Store) Close() error {
111+ if s.db != nil {
112+ return s.db.Close()
113+ }
114+ return nil
115+}
116+117+// DB returns the underlying BoltDB instance for advanced operations.
118+func (s *Store) DB() *bolt.DB {
119+ return s.db
120+}
121+122+// SessionStore returns an OAuth session store backed by this database.
123+func (s *Store) SessionStore() *SessionStore {
124+ return &SessionStore{db: s.db}
125+}
126+127+// FeedStore returns a feed registry store backed by this database.
128+func (s *Store) FeedStore() *FeedStore {
129+ return &FeedStore{db: s.db}
130+}
131+132+// Stats returns database statistics.
133+func (s *Store) Stats() bolt.Stats {
134+ return s.db.Stats()
135+}
+55-10
internal/feed/registry.go
···4 "sync"
5)
600000000007// Registry tracks known Arabica users (DIDs) for the social feed.
8-// This is an in-memory store that gets populated as users log in.
9-// In the future, this could be persisted to a database.
10type Registry struct {
11- mu sync.RWMutex
12- dids map[string]struct{}
013}
1415-// NewRegistry creates a new user registry
16func NewRegistry() *Registry {
17 return &Registry{
18 dids: make(map[string]struct{}),
19 }
20}
2122-// Register adds a DID to the registry
000000000000000000023func (r *Registry) Register(did string) {
24 r.mu.Lock()
25 defer r.mu.Unlock()
0026 r.dids[did] = struct{}{}
000000027}
2829-// Unregister removes a DID from the registry
030func (r *Registry) Unregister(did string) {
31 r.mu.Lock()
32 defer r.mu.Unlock()
033 delete(r.dids, did)
000034}
3536-// IsRegistered checks if a DID is in the registry
37func (r *Registry) IsRegistered(did string) bool {
38 r.mu.RLock()
39 defer r.mu.RUnlock()
···41 return ok
42}
4344-// List returns all registered DIDs
45func (r *Registry) List() []string {
46 r.mu.RLock()
47 defer r.mu.RUnlock()
···53 return dids
54}
5556-// Count returns the number of registered users
57func (r *Registry) Count() int {
58 r.mu.RLock()
59 defer r.mu.RUnlock()
···4 "sync"
5)
67+// PersistentStore defines the interface for persistent feed registry storage.
8+// This allows the Registry to optionally persist DIDs to a database.
9+type PersistentStore interface {
10+ Register(did string) error
11+ Unregister(did string) error
12+ IsRegistered(did string) bool
13+ List() []string
14+ Count() int
15+}
16+17// Registry tracks known Arabica users (DIDs) for the social feed.
18+// It maintains an in-memory cache for fast access and optionally
19+// persists data to a backing store.
20type Registry struct {
21+ mu sync.RWMutex
22+ dids map[string]struct{}
23+ store PersistentStore // Optional persistent backing store
24}
2526+// NewRegistry creates a new user registry with in-memory storage only.
27func NewRegistry() *Registry {
28 return &Registry{
29 dids: make(map[string]struct{}),
30 }
31}
3233+// NewPersistentRegistry creates a new registry backed by persistent storage.
34+// It loads existing registrations from the store on creation.
35+func NewPersistentRegistry(store PersistentStore) *Registry {
36+ r := &Registry{
37+ dids: make(map[string]struct{}),
38+ store: store,
39+ }
40+41+ // Load existing registrations from store
42+ if store != nil {
43+ for _, did := range store.List() {
44+ r.dids[did] = struct{}{}
45+ }
46+ }
47+48+ return r
49+}
50+51+// Register adds a DID to the registry.
52+// If a persistent store is configured, the DID is also saved to the store.
53func (r *Registry) Register(did string) {
54 r.mu.Lock()
55 defer r.mu.Unlock()
56+57+ // Add to in-memory cache
58 r.dids[did] = struct{}{}
59+60+ // Persist if store is configured
61+ if r.store != nil {
62+ // Ignore errors for now - the in-memory cache is the source of truth
63+ // during this session, and we'll retry on next restart
64+ _ = r.store.Register(did)
65+ }
66}
6768+// Unregister removes a DID from the registry.
69+// If a persistent store is configured, the DID is also removed from the store.
70func (r *Registry) Unregister(did string) {
71 r.mu.Lock()
72 defer r.mu.Unlock()
73+74 delete(r.dids, did)
75+76+ if r.store != nil {
77+ _ = r.store.Unregister(did)
78+ }
79}
8081+// IsRegistered checks if a DID is in the registry.
82func (r *Registry) IsRegistered(did string) bool {
83 r.mu.RLock()
84 defer r.mu.RUnlock()
···86 return ok
87}
8889+// List returns all registered DIDs.
90func (r *Registry) List() []string {
91 r.mu.RLock()
92 defer r.mu.RUnlock()
···98 return dids
99}
100101+// Count returns the number of registered users.
102func (r *Registry) Count() int {
103 r.mu.RLock()
104 defer r.mu.RUnlock()