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 }