package store import ( "context" "errors" "time" "armature/models" ) // ── Sentinel Errors ───────────────────────── // ErrSystemGroup is returned when attempting to delete a system-sourced group. var ErrSystemGroup = errors.New("system groups cannot be deleted") // RefreshTokenInfo holds extended metadata for a refresh token row. type RefreshTokenInfo struct { UserID string KeepLogin bool LastActivityAt *time.Time } // ========================================= // STORES — Data Access Layer // ========================================= // Every database operation goes through these interfaces. // Handlers never touch SQL directly. This makes the DB // portable (Postgres today, SQLite/MySQL later) and // handlers testable with in-memory implementations. // ========================================= // Stores bundles all store interfaces for dependency injection. // PolicyStore provides platform policy flags (allow_registration, etc.) type PolicyStore interface { GetBool(ctx context.Context, key string) (bool, error) SetBool(ctx context.Context, key string, val bool) error Get(ctx context.Context, key string) (string, error) Set(ctx context.Context, key, value string) error GetAll(ctx context.Context) (map[string]string, error) } type Stores struct { Users UserStore Teams TeamStore Audit AuditStore GlobalConfig GlobalConfigStore Policies PolicyStore Groups GroupStore ResourceGrants ResourceGrantStore Notifications NotificationStore NotifPrefs NotificationPreferenceStore Presence PresenceStore Health HealthStore Connections ConnectionStore Dependencies DependencyStore Packages PackageStore Workflows WorkflowStore ExtPermissions ExtensionPermissionStore ExtData ExtDataStore Tickets TicketStore RateLimits RateLimitStore Triggers TriggerStore ScheduledTasks ScheduledTaskStore Cluster ClusterStore APITokens APITokenStore } // TeamAvailableModel is returned by CatalogStore.ListTeamAvailable. // CatalogSyncEntry is the input format from provider FetchModels. // ========================================= // USER STORE // ========================================= type UserStore interface { Create(ctx context.Context, u *models.User) error GetByID(ctx context.Context, id string) (*models.User, error) GetByUsername(ctx context.Context, username string) (*models.User, error) GetByEmail(ctx context.Context, email string) (*models.User, error) GetByLogin(ctx context.Context, login string) (*models.User, error) // username or email GetByHandle(ctx context.Context, handle string) (*models.User, error) GetByExternalID(ctx context.Context, authSource, externalID string) (*models.User, error) Update(ctx context.Context, id string, fields map[string]interface{}) error Delete(ctx context.Context, id string) error List(ctx context.Context, opts ListOptions) ([]models.User, int, error) UpdateLastLogin(ctx context.Context, id string) error SetActive(ctx context.Context, id string, active bool) error ListActiveUserIDs(ctx context.Context) ([]string, error) // Refresh tokens CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error GetRefreshToken(ctx context.Context, tokenHash string) (userID string, err error) GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*RefreshTokenInfo, error) RevokeRefreshToken(ctx context.Context, tokenHash string) error RevokeAllRefreshTokens(ctx context.Context, userID string) error CleanExpiredTokens(ctx context.Context) error UpdateRefreshTokenActivity(ctx context.Context, userID string) error // ── CS1 additions ── // Exists returns true if a user with the given ID exists and is active. Exists(ctx context.Context, userID string) (bool, error) // SearchActive returns users matching a query (username, display_name, handle). // Excludes the calling user. Max 20 results. SearchActive(ctx context.Context, excludeUserID, query string) ([]UserSearchResult, error) // ResolveByIDs returns lightweight identity records for the given user IDs. // Missing IDs are silently omitted. Max 100 IDs. ResolveByIDs(ctx context.Context, ids []string) ([]UserSearchResult, error) // ── CS2 additions ── // CountAll returns the total number of users. CountAll(ctx context.Context) (int, error) // MergeSettings performs a dialect-aware JSON merge into the user's settings column. MergeSettings(ctx context.Context, userID string, patch []byte) error // GetVaultKeys returns the user's vault encryption state. GetVaultKeys(ctx context.Context, userID string) (vaultSet bool, encUEK, salt, nonce []byte, err error) // UpdateVaultKeys re-wraps the user's vault keys (password change). UpdateVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error // ── CS4 additions ── // ClearVaultKeys nulls out vault columns and sets vault_set = false. ClearVaultKeys(ctx context.Context, userID string) error // InitVaultKeys stores vault keys and sets vault_set = true (first-time init). InitVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error } // UserSearchResult is a lightweight result for user search. type UserSearchResult struct { ID string `json:"id"` Username string `json:"username"` DisplayName string `json:"display_name"` Handle string `json:"handle"` AvatarURL string `json:"avatar_url,omitempty"` } // ========================================= // TEAM STORE // ========================================= type TeamStore interface { Create(ctx context.Context, t *models.Team) error GetByID(ctx context.Context, id string) (*models.Team, error) Update(ctx context.Context, id string, fields map[string]interface{}) error Delete(ctx context.Context, id string) error List(ctx context.Context) ([]models.Team, error) ListForUser(ctx context.Context, userID string) ([]models.Team, error) // active teams with MyRole // Members AddMember(ctx context.Context, teamID, userID, role string) error RemoveMember(ctx context.Context, teamID, userID string) error UpdateMemberRole(ctx context.Context, teamID, userID, role string) error ListMembers(ctx context.Context, teamID string) ([]models.TeamMember, error) GetMember(ctx context.Context, teamID, userID string) (*models.TeamMember, error) GetUserTeamIDs(ctx context.Context, userID string) ([]string, error) IsTeamAdmin(ctx context.Context, teamID, userID string) (bool, error) IsMember(ctx context.Context, teamID, userID string) (bool, error) // ── CS1 additions ── // Exists returns true if a team with the given ID exists. Exists(ctx context.Context, teamID string) (bool, error) // UpdateMemberRoleByID updates a member's role using the member row ID. UpdateMemberRoleByID(ctx context.Context, memberID, teamID, role string) (int64, error) // DeleteMemberByID deletes a member using the member row ID. DeleteMemberByID(ctx context.Context, memberID, teamID string) (int64, error) // ListTeamAuditActions returns distinct audit actions for a team's members. ListTeamAuditActions(ctx context.Context, teamID string) ([]string, error) // ── CS5b additions ── // GetFirstTeamIDForUser returns the first team_id the user belongs to, or "" if none. GetFirstTeamIDForUser(ctx context.Context, userID string) (string, error) // ── CS6 additions ── // AddMemberReturningID inserts a team member and returns the row ID. AddMemberReturningID(ctx context.Context, teamID, userID, role string) (string, error) // MergeSettings merges a JSON string into the team's settings column. MergeSettings(ctx context.Context, teamID, settingsJSON string) error // ── v0.9.3 — Team User Roles (many-to-many) ── // AddUserRole assigns an additional role to a team member. Idempotent. AddUserRole(ctx context.Context, teamID, userID, role, assignedBy string) error // RemoveUserRole removes one additional role from a team member. RemoveUserRole(ctx context.Context, teamID, userID, role string) error // ListUserRoles returns the additional roles for a member (excludes primary). ListUserRoles(ctx context.Context, teamID, userID string) ([]string, error) // GetMemberRoles returns the full effective role set (primary + additional). GetMemberRoles(ctx context.Context, teamID, userID string) ([]string, error) // HasRole checks whether a user holds a specific role (primary or additional). HasRole(ctx context.Context, teamID, userID, role string) (bool, error) // HasRoleInAnyTeam checks whether a user holds a specific role in any team. HasRoleInAnyTeam(ctx context.Context, userID, role string) (bool, error) // RemoveAllUserRoles deletes all additional roles for a member (cleanup on removal). RemoveAllUserRoles(ctx context.Context, teamID, userID string) error // ── v0.9.4 — Team Role Catalog ── // AddRoleToCatalog registers a known role for the team. Idempotent. AddRoleToCatalog(ctx context.Context, teamID, role, sourcePkgID string) error // ListRoleCatalog returns all known roles for a team. ListRoleCatalog(ctx context.Context, teamID string) ([]TeamRoleCatalogEntry, error) // RemoveRoleCatalogBySource removes all catalog entries sourced from a package. RemoveRoleCatalogBySource(ctx context.Context, teamID, sourcePkgID string) error } // TeamRoleCatalogEntry represents a known role sourced from an adopted package. type TeamRoleCatalogEntry struct { Role string `json:"role"` SourcePackageID string `json:"source_package_id,omitempty"` } // ========================================= // AUDIT STORE // ========================================= type AuditStore interface { Log(ctx context.Context, entry *models.AuditEntry) error List(ctx context.Context, opts AuditListOptions) ([]models.AuditEntry, int, error) ListActions(ctx context.Context) ([]string, error) } type AuditListOptions struct { ListOptions ActorID string Action string ResourceType string ResourceID string Since *time.Time Until *time.Time TeamID string // CS6: scope to team members } // NoteSearchResult is returned by NoteStore.SearchKeyword and SearchSemantic. // FolderInfo is returned by NoteStore.ListFolders. // ========================================= // GLOBAL CONFIG STORE // ========================================= type GlobalConfigStore interface { Get(ctx context.Context, key string) (models.JSONMap, error) Set(ctx context.Context, key string, value models.JSONMap, updatedBy string) error GetAll(ctx context.Context) (map[string]models.JSONMap, error) // ── OIDC state ── // SaveOIDCState stores a state+nonce pair for OIDC callback verification. SaveOIDCState(ctx context.Context, state, nonce, redirectTo string) error // ConsumeOIDCState retrieves and deletes an OIDC state (one-time use). // Returns (nonce, redirectTo, error). Returns sql.ErrNoRows if not found. ConsumeOIDCState(ctx context.Context, state string) (nonce, redirectTo string, err error) // CleanupOIDCState removes stale OIDC states older than 10 minutes. CleanupOIDCState(ctx context.Context) error // ── CS6 additions ── // GetString returns the raw value column as a string for simple settings // (e.g. bare JSON booleans like "true"/"false"). GetString(ctx context.Context, key string) (string, error) } // ========================================= // GROUP STORE // ========================================= type GroupStore interface { Create(ctx context.Context, g *models.Group) error GetByID(ctx context.Context, id string) (*models.Group, error) Update(ctx context.Context, id string, patch models.GroupPatch) error Delete(ctx context.Context, id string) error // Scoped listing ListAll(ctx context.Context) ([]models.Group, error) // admin ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) // team admin ListForUser(ctx context.Context, userID string) ([]models.Group, error) // groups I belong to // Members AddMember(ctx context.Context, groupID, userID, addedBy string) error RemoveMember(ctx context.Context, groupID, userID string) error ListMembers(ctx context.Context, groupID string) ([]models.GroupMember, error) IsMember(ctx context.Context, groupID, userID string) (bool, error) // For access resolution: returns group IDs a user belongs to GetUserGroupIDs(ctx context.Context, userID string) ([]string, error) } // ========================================= // RESOURCE GRANT STORE // ========================================= type ResourceGrantStore interface { Set(ctx context.Context, grant *models.ResourceGrant) error Get(ctx context.Context, resourceType, resourceID string) (*models.ResourceGrant, error) Delete(ctx context.Context, resourceType, resourceID string) error // Access check: does userID have group-based access to this resource? UserHasGroupAccess(ctx context.Context, userID, resourceType, resourceID string) (bool, error) } // ========================================= // NOTIFICATION STORE // ========================================= type NotificationStore interface { Create(ctx context.Context, n *models.Notification) error ListByUser(ctx context.Context, userID string, limit, offset int, unreadOnly bool) ([]models.Notification, int, error) MarkRead(ctx context.Context, id, userID string) error MarkAllRead(ctx context.Context, userID string) error Delete(ctx context.Context, id, userID string) error UnreadCount(ctx context.Context, userID string) (int, error) DeleteOlderThan(ctx context.Context, before time.Time) (int64, error) } // ========================================= // NOTIFICATION PREFERENCES STORE // ========================================= type NotificationPreferenceStore interface { // Get returns the preference for a specific user + type. Returns nil if not set. Get(ctx context.Context, userID, notifType string) (*models.NotificationPreference, error) // ListForUser returns all preferences set by a user. ListForUser(ctx context.Context, userID string) ([]models.NotificationPreference, error) // Upsert creates or updates a preference row. Upsert(ctx context.Context, pref *models.NotificationPreference) error // Delete removes a preference (falls back to default). Delete(ctx context.Context, userID, notifType string) error } // ========================================= // SESSION STORE // ========================================= // ========================================= // HEALTH STORE // ========================================= // HealthStore manages provider health windows. The full interface is used // by the health accumulator and status querier; the scheduler only needs Prune. type HealthStore interface { // Prune deletes health windows older than the given time. Returns rows affected. Prune(ctx context.Context, before time.Time) (int64, error) } // ========================================= // PRESENCE STORE // ========================================= // PresenceStore manages user online/offline status. type PresenceStore interface { // Heartbeat upserts the user's last_seen timestamp to now. Heartbeat(ctx context.Context, userID string) error // GetLastSeen returns the user's last heartbeat time, or nil if never seen. GetLastSeen(ctx context.Context, userID string) (*time.Time, error) // GetStatuses returns online/offline status for a list of user IDs. // Users with last_seen after threshold are "online", otherwise "offline". GetStatuses(ctx context.Context, userIDs []string, threshold time.Time) (map[string]string, error) } // ========================================= // SHARED TYPES // ========================================= // CONNECTION STORE // ========================================= type ConnectionStore interface { Create(ctx context.Context, conn *models.ExtConnection) error GetByID(ctx context.Context, id string) (*models.ExtConnection, error) Update(ctx context.Context, id string, patch models.ExtConnectionPatch) error Delete(ctx context.Context, id string) error // Scoped queries ListGlobal(ctx context.Context) ([]models.ExtConnection, error) ListForTeam(ctx context.Context, teamID string) ([]models.ExtConnection, error) ListForUser(ctx context.Context, userID string) ([]models.ExtConnection, error) // Resolution chain: personal → team → global. // Returns first active connection matching type (and optional name). // Empty name means "first match". Returns sql.ErrNoRows if not found. Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error) // ResolveAll returns all active connections of the given type accessible // to the user (personal + team + global). ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error) // ListAccessible returns all connections accessible to the user across // all types: personal + team memberships + global. ListAccessible(ctx context.Context, userID string) ([]models.ExtConnection, error) // DeleteByIDAndScope deletes a connection only if it matches the given // scope and owner. Returns rows affected (0 or 1). DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) } // ========================================= // DEPENDENCY STORE // ========================================= type DependencyStore interface { // Create records that consumerID depends on libraryID. Create(ctx context.Context, dep *models.ExtDependency) error // Delete removes a single dependency record. Delete(ctx context.Context, consumerID, libraryID string) error // DeleteAllForConsumer removes all dependency records for a consumer. // Called when uninstalling a consumer package. DeleteAllForConsumer(ctx context.Context, consumerID string) error // ListByConsumer returns all libraries that consumerID depends on. ListByConsumer(ctx context.Context, consumerID string) ([]models.ExtDependency, error) // ListByLibrary returns all consumers that depend on libraryID. ListByLibrary(ctx context.Context, libraryID string) ([]models.ExtDependency, error) // ListAll returns the full dependency graph. ListAll(ctx context.Context) ([]models.ExtDependency, error) // HasConsumers returns true if any package depends on libraryID. // Used for uninstall protection. HasConsumers(ctx context.Context, libraryID string) (bool, error) } // ========================================= // ListOptions provides standard pagination/sort for list queries. type ListOptions struct { Limit int Offset int Sort string // column name Order string // "asc" or "desc" } // DefaultListOptions returns sensible defaults. func DefaultListOptions() ListOptions { return ListOptions{ Limit: 50, Order: "desc", } }