package sqlite import ( "context" "database/sql" "encoding/json" "time" "armature/models" "github.com/google/uuid" ) // APITokenStore implements store.APITokenStore for SQLite. type APITokenStore struct{} // NewAPITokenStore creates a new SQLite API token store. func NewAPITokenStore() *APITokenStore { return &APITokenStore{} } func (s *APITokenStore) Create(ctx context.Context, token *models.APIToken) error { token.ID = uuid.New().String() token.CreatedAt = time.Now().UTC() permsJSON, err := json.Marshal(token.Permissions) if err != nil { return err } _, err = DB.ExecContext(ctx, `INSERT INTO api_tokens (id, user_id, name, token_hash, prefix, permissions, expires_at, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, token.ID, token.UserID, token.Name, token.TokenHash, token.Prefix, string(permsJSON), formatNullableTime(token.ExpiresAt), token.CreatedBy, token.CreatedAt.UTC().Format(time.RFC3339), ) return err } func (s *APITokenStore) GetByHash(ctx context.Context, tokenHash string) (*models.APIToken, error) { row := DB.QueryRowContext(ctx, `SELECT id, user_id, name, token_hash, prefix, permissions, expires_at, last_used_at, created_at, created_by FROM api_tokens WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > datetime('now'))`, tokenHash) var t models.APIToken var permsJSON string var expiresAt, lastUsedAt, createdAt sql.NullString var createdBy sql.NullString err := row.Scan(&t.ID, &t.UserID, &t.Name, &t.TokenHash, &t.Prefix, &permsJSON, &expiresAt, &lastUsedAt, &createdAt, &createdBy) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } if err := json.Unmarshal([]byte(permsJSON), &t.Permissions); err != nil { t.Permissions = []string{} } t.ExpiresAt = parseNullableTime(expiresAt) t.LastUsedAt = parseNullableTime(lastUsedAt) if createdAt.Valid { if ts, err := time.Parse(time.RFC3339, createdAt.String); err == nil { t.CreatedAt = ts } } if createdBy.Valid { t.CreatedBy = &createdBy.String } return &t, nil } func (s *APITokenStore) ListForUser(ctx context.Context, userID string) ([]models.APIToken, error) { rows, err := DB.QueryContext(ctx, `SELECT id, user_id, name, prefix, permissions, expires_at, last_used_at, created_at, created_by FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC`, userID) if err != nil { return nil, err } defer rows.Close() var tokens []models.APIToken for rows.Next() { var t models.APIToken var permsJSON string var expiresAt, lastUsedAt, createdAt sql.NullString var createdBy sql.NullString if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.Prefix, &permsJSON, &expiresAt, &lastUsedAt, &createdAt, &createdBy); err != nil { return nil, err } if err := json.Unmarshal([]byte(permsJSON), &t.Permissions); err != nil { t.Permissions = []string{} } t.ExpiresAt = parseNullableTime(expiresAt) t.LastUsedAt = parseNullableTime(lastUsedAt) if createdAt.Valid { if ts, err := time.Parse(time.RFC3339, createdAt.String); err == nil { t.CreatedAt = ts } } if createdBy.Valid { t.CreatedBy = &createdBy.String } tokens = append(tokens, t) } if tokens == nil { tokens = []models.APIToken{} } return tokens, nil } func (s *APITokenStore) Revoke(ctx context.Context, id, userID string) (int64, error) { res, err := DB.ExecContext(ctx, `DELETE FROM api_tokens WHERE id = ? AND user_id = ?`, id, userID) if err != nil { return 0, err } return res.RowsAffected() } func (s *APITokenStore) RevokeByID(ctx context.Context, id string) (int64, error) { res, err := DB.ExecContext(ctx, `DELETE FROM api_tokens WHERE id = ?`, id) if err != nil { return 0, err } return res.RowsAffected() } func (s *APITokenStore) CleanExpired(ctx context.Context) (int64, error) { res, err := DB.ExecContext(ctx, `DELETE FROM api_tokens WHERE expires_at IS NOT NULL AND expires_at < datetime('now')`) if err != nil { return 0, err } return res.RowsAffected() } func (s *APITokenStore) UpdateLastUsed(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, `UPDATE api_tokens SET last_used_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id) return err } // ── Helpers ────────────────────────────────── func formatNullableTime(t *time.Time) interface{} { if t == nil { return nil } return t.UTC().Format(time.RFC3339) } func parseNullableTime(ns sql.NullString) *time.Time { if !ns.Valid || ns.String == "" { return nil } t, err := time.Parse(time.RFC3339, ns.String) if err != nil { return nil } return &t }