All checks were successful
CI/CD / detect-changes (pull_request) Successful in 21s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m9s
CI/CD / test-sqlite (pull_request) Successful in 2m50s
CI/CD / build-and-deploy (pull_request) Successful in 1m50s
Team admin settings audit (pass 1): remove dead BYOK/provider/persona vestiges from team store, models, policy defaults, workflow stage UI, and ICD tests. Fix stale chat_only stage mode in frontend to match backend CHECK constraint. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
414 lines
17 KiB
Go
414 lines
17 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"switchboard-core/models"
|
|
)
|
|
|
|
// ── Sentinel Errors ─────────────────────────
|
|
|
|
// ErrSystemGroup is returned when attempting to delete a system-sourced group.
|
|
var ErrSystemGroup = errors.New("system groups cannot be deleted")
|
|
|
|
// =========================================
|
|
// 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 // v0.29.0: User online/offline status
|
|
Health HealthStore // v0.29.0: Provider health window management
|
|
Connections ConnectionStore // v0.38.1: Extension connection credentials
|
|
Dependencies DependencyStore // v0.38.2: Library package dependency graph
|
|
Packages PackageStore // v0.28.7: Unified package registry (surfaces + extensions)
|
|
Workflows WorkflowStore // v0.26.1: Workflow definitions + stages
|
|
ExtPermissions ExtensionPermissionStore // v0.29.0: Extension declared/granted capabilities
|
|
ExtData ExtDataStore // v0.29.2: Extension namespaced table catalog
|
|
Tickets TicketStore // v0.32.0: WS auth tickets (PG-backed for cross-pod)
|
|
RateLimits RateLimitStore // v0.32.0: Distributed rate limiting
|
|
Triggers TriggerStore // v0.2.2: Extension event/webhook triggers
|
|
ScheduledTasks ScheduledTaskStore // v0.2.2: User-created cron tasks
|
|
}
|
|
|
|
// 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) error
|
|
GetRefreshToken(ctx context.Context, tokenHash string) (userID string, err error)
|
|
RevokeRefreshToken(ctx context.Context, tokenHash string) error
|
|
RevokeAllRefreshTokens(ctx context.Context, userID string) error
|
|
CleanExpiredTokens(ctx context.Context) error
|
|
|
|
// ── CS1 additions (v0.29.0) ──
|
|
|
|
// 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)
|
|
|
|
// ── CS2 additions (v0.29.0) ──
|
|
|
|
// 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 (v0.29.0) ──
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// =========================================
|
|
// 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 (v0.29.0) ──
|
|
|
|
// 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 (v0.29.0) ──
|
|
|
|
// GetFirstTeamIDForUser returns the first team_id the user belongs to, or "" if none.
|
|
GetFirstTeamIDForUser(ctx context.Context, userID string) (string, error)
|
|
|
|
// ── CS6 additions (v0.29.0) ──
|
|
|
|
// 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
|
|
}
|
|
|
|
// ChannelListFilter holds filter options for ChannelStore.ListFiltered.
|
|
// ChannelListItem is returned by ListFiltered and GetForUser.
|
|
// ArchivedChannel is a view model for the admin archived channels list.
|
|
// MessageWithSender is returned by MessageStore.ListWithSenderInfo.
|
|
// ChannelSearchResult is returned by MessageStore.SearchInChannel.
|
|
// =========================================
|
|
// 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 (v0.29.0-cs4) ──
|
|
|
|
// 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 (v0.29.0) ──
|
|
|
|
// 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 (v0.16.0)
|
|
// =========================================
|
|
|
|
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 (v0.16.0)
|
|
// =========================================
|
|
|
|
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 (v0.20.0)
|
|
// =========================================
|
|
|
|
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 (v0.20.0)
|
|
// =========================================
|
|
|
|
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 (v0.24.3)
|
|
// =========================================
|
|
|
|
|
|
// =========================================
|
|
// HEALTH STORE (v0.29.0)
|
|
// =========================================
|
|
|
|
// 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 (v0.29.0)
|
|
// =========================================
|
|
|
|
// 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 (v0.38.1)
|
|
// =========================================
|
|
|
|
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 (v0.38.2)
|
|
// =========================================
|
|
|
|
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",
|
|
}
|
|
}
|