Files
skrueckenandCursor 4adfcdb95a Add tm_server with SQLite, Svelte UI, and task-master TUI sync.
Introduces the web backend (auth, RBAC, project tokens, revisions),
YouTrack-or-taskmaster backend choice in the TUI, and a first-pass
embedded Svelte UI. The frontend is functional but needs a major rework.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 22:23:11 +01:00

227 lines
5.9 KiB
Go

package sqlite
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
const (
TokenScopeRead = "read"
TokenScopeWrite = "write" // includes read
)
// ProjectToken is a project-scoped API credential (secret only on create).
type ProjectToken struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
Name string `json:"name"`
Scope string `json:"scope"`
CreatedBy string `json:"createdBy,omitempty"`
CreatedAt time.Time `json:"createdAt"`
RevokedAt *time.Time `json:"revokedAt,omitempty"`
Token string `json:"token,omitempty"` // raw secret, only set on create
}
// CreateProjectToken issues a new token; raw value is returned once in Token.
func (db *DB) CreateProjectToken(projectID, name, scope, createdBy string) (ProjectToken, error) {
name = strings.TrimSpace(name)
scope = strings.TrimSpace(scope)
if projectID == "" || name == "" {
return ProjectToken{}, fmt.Errorf("projectId and name required")
}
if scope != TokenScopeRead && scope != TokenScopeWrite {
return ProjectToken{}, fmt.Errorf("scope must be read or write")
}
raw, err := randomToken()
if err != nil {
return ProjectToken{}, err
}
hash := hashToken(raw)
t := ProjectToken{
ID: uuid.NewString(),
ProjectID: projectID,
Name: name,
Scope: scope,
CreatedBy: createdBy,
CreatedAt: time.Now().UTC(),
Token: raw,
}
_, err = db.SQL.Exec(`
INSERT INTO project_tokens(id, project_id, name, token_hash, scope, created_by, created_at)
VALUES(?,?,?,?,?,?,?)`,
t.ID, t.ProjectID, t.Name, hash, t.Scope, nullStr(createdBy), formatTime(t.CreatedAt))
if err != nil {
return ProjectToken{}, err
}
return t, nil
}
func randomToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return "tm_" + base64.RawURLEncoding.EncodeToString(buf), nil
}
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return base64.RawStdEncoding.EncodeToString(sum[:])
}
func nullStr(s string) any {
if s == "" {
return nil
}
return s
}
// ListProjectTokens returns non-secret token metadata for a project.
func (db *DB) ListProjectTokens(projectID string) ([]ProjectToken, error) {
rows, err := db.SQL.Query(`
SELECT id, project_id, name, scope, created_by, created_at, revoked_at
FROM project_tokens WHERE project_id = ?
ORDER BY created_at DESC`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ProjectToken
for rows.Next() {
t, err := scanProjectToken(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
if out == nil {
out = []ProjectToken{}
}
return out, rows.Err()
}
// RevokeProjectToken soft-revokes a token.
func (db *DB) RevokeProjectToken(projectID, tokenID string) error {
res, err := db.SQL.Exec(`
UPDATE project_tokens SET revoked_at = ? WHERE id = ? AND project_id = ? AND revoked_at IS NULL`,
formatTime(time.Now().UTC()), tokenID, projectID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("token not found or already revoked")
}
return nil
}
// TokenAuth is the resolved bearer identity.
type TokenAuth struct {
User User
ProjectID string
Scope string
TokenID string
}
// ResolveBearerToken validates a raw API token.
func (db *DB) ResolveBearerToken(raw string) (TokenAuth, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return TokenAuth{}, fmt.Errorf("empty token")
}
hash := hashToken(raw)
var (
tokenID, projectID, scope string
createdBy sql.NullString
revoked sql.NullString
)
err := db.SQL.QueryRow(`
SELECT id, project_id, scope, created_by, revoked_at
FROM project_tokens WHERE token_hash = ?`, hash).
Scan(&tokenID, &projectID, &scope, &createdBy, &revoked)
if err == sql.ErrNoRows {
return TokenAuth{}, fmt.Errorf("invalid token")
}
if err != nil {
return TokenAuth{}, err
}
if revoked.Valid && revoked.String != "" {
return TokenAuth{}, fmt.Errorf("token revoked")
}
var u User
if createdBy.Valid && createdBy.String != "" {
u, err = db.GetUserByID(createdBy.String)
if err != nil {
// creator deleted — synthetic limited principal
u = User{ID: createdBy.String, Username: "token", IsAdmin: false}
}
} else {
u = User{ID: "token:" + tokenID, Username: "token", IsAdmin: false}
}
u.PasswordHash = ""
return TokenAuth{User: u, ProjectID: projectID, Scope: scope, TokenID: tokenID}, nil
}
// TokenAllows reports whether scope permits the operation (write implies read).
func TokenAllows(scope, need string) bool {
if need == TokenScopeRead {
return scope == TokenScopeRead || scope == TokenScopeWrite
}
return scope == TokenScopeWrite
}
func scanProjectToken(scanner interface{ Scan(dest ...any) error }) (ProjectToken, error) {
var t ProjectToken
var createdBy sql.NullString
var created, revoked sql.NullString
err := scanner.Scan(&t.ID, &t.ProjectID, &t.Name, &t.Scope, &createdBy, &created, &revoked)
if err != nil {
return ProjectToken{}, err
}
if createdBy.Valid {
t.CreatedBy = createdBy.String
}
t.CreatedAt = parseTime(created.String)
if revoked.Valid && revoked.String != "" {
tm := parseTime(revoked.String)
t.RevokedAt = &tm
}
return t, nil
}
// DeleteUser removes a user and their sessions.
func (db *DB) DeleteUser(id string) error {
if id == "" {
return fmt.Errorf("user id required")
}
res, err := db.SQL.Exec(`DELETE FROM users WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("user not found")
}
return nil
}
// SetUserAdmin updates the is_admin flag.
func (db *DB) SetUserAdmin(id string, isAdmin bool) error {
res, err := db.SQL.Exec(`UPDATE users SET is_admin = ? WHERE id = ?`, boolInt(isAdmin), id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("user not found")
}
return nil
}