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>
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user