65771ebb12
Net-new capacidades recuperadas del WIP stash que el merge notif no traia: - mint-token CLI subcommand: 'kanban mint-token --user <id> --name <pc>' genera token bearer para configurar Claude Code u otros clientes MCP HTTP sin tocar la UI. - executeToolAs(db, name, input, actor): variante actor-aware de executeTool. El dispatcher HTTP /mcp pasa el user_id resuelto del bearer token; tools per-user (add_comment, delete_comment) lo usan como autor sin que el llamante pueda forjarlo. - get_card tool: lookup por id o seq_num. Devuelve Card completa. - delete_comment tool: borra card_message; solo el autor original (validado en DB). executeTool() sigue siendo el wrapper legacy sin actor para chat WS.
175 lines
4.9 KiB
Go
175 lines
4.9 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
)
|
|
|
|
// MCPToken is a per-user access token used by remote Claude clients to talk to
|
|
// the kanban MCP HTTP endpoint. The plaintext value is shown ONCE at creation
|
|
// time; we only persist the SHA-256 hash.
|
|
type MCPToken struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
CreatedAt string `json:"created_at"`
|
|
LastUsedAt *string `json:"last_used_at,omitempty"`
|
|
}
|
|
|
|
const mcpTokenPrefix = "kmcp_"
|
|
|
|
var errMCPTokenNotFound = errors.New("mcp token not found")
|
|
|
|
// MintMCPToken creates a new active token for userID and returns the plaintext
|
|
// value (caller must surface it to the user immediately; it cannot be
|
|
// recovered later) along with the row metadata.
|
|
func (db *DB) MintMCPToken(userID, name string) (string, *MCPToken, error) {
|
|
if userID == "" {
|
|
return "", nil, fmt.Errorf("user_id required")
|
|
}
|
|
plaintext, err := generateMCPTokenPlaintext()
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("generate token: %w", err)
|
|
}
|
|
tok := &MCPToken{
|
|
ID: newID(),
|
|
Name: name,
|
|
CreatedAt: nowRFC3339(),
|
|
}
|
|
_, err = db.conn.Exec(
|
|
`INSERT INTO mcp_tokens (id, user_id, token_hash, name, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
tok.ID, userID, hashMCPToken(plaintext), tok.Name, tok.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return plaintext, tok, nil
|
|
}
|
|
|
|
func (db *DB) ListMCPTokens(userID string) ([]MCPToken, error) {
|
|
rows, err := db.conn.Query(
|
|
`SELECT id, name, created_at, last_used_at FROM mcp_tokens
|
|
WHERE user_id=? AND revoked_at IS NULL
|
|
ORDER BY created_at DESC`, userID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []MCPToken{}
|
|
for rows.Next() {
|
|
var t MCPToken
|
|
var lastUsed sql.NullString
|
|
if err := rows.Scan(&t.ID, &t.Name, &t.CreatedAt, &lastUsed); err != nil {
|
|
return nil, err
|
|
}
|
|
if lastUsed.Valid {
|
|
t.LastUsedAt = &lastUsed.String
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// RevokeMCPToken sets revoked_at on the token belonging to userID. Returns
|
|
// errMCPTokenNotFound if no active row matches.
|
|
func (db *DB) RevokeMCPToken(userID, tokenID string) error {
|
|
res, err := db.conn.Exec(
|
|
`UPDATE mcp_tokens SET revoked_at=? WHERE id=? AND user_id=? AND revoked_at IS NULL`,
|
|
nowRFC3339(), tokenID, userID,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n == 0 {
|
|
return errMCPTokenNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LookupMCPToken hashes plaintext and returns the owning user_id if the token
|
|
// is active. Updates last_used_at as a side effect. Returns "" + nil when the
|
|
// token does not match an active row.
|
|
func (db *DB) LookupMCPToken(plaintext string) (string, error) {
|
|
if plaintext == "" {
|
|
return "", nil
|
|
}
|
|
hash := hashMCPToken(plaintext)
|
|
var userID, id string
|
|
err := db.conn.QueryRow(
|
|
`SELECT id, user_id FROM mcp_tokens WHERE token_hash=? AND revoked_at IS NULL`, hash,
|
|
).Scan(&id, &userID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", nil
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := db.conn.Exec(`UPDATE mcp_tokens SET last_used_at=? WHERE id=?`, nowRFC3339(), id); err != nil {
|
|
return userID, fmt.Errorf("touch last_used_at: %w", err)
|
|
}
|
|
return userID, nil
|
|
}
|
|
|
|
func hashMCPToken(plaintext string) string {
|
|
sum := sha256.Sum256([]byte(plaintext))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func generateMCPTokenPlaintext() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return mcpTokenPrefix + hex.EncodeToString(b), nil
|
|
}
|
|
|
|
// runMintToken implements `kanban mint-token --user <id> --name <pc>`.
|
|
// Generates a fresh token, persists its sha256 in mcp_tokens, and prints the
|
|
// plaintext ONCE to stdout. The caller must save it — the server keeps only
|
|
// the hash.
|
|
func runMintToken(args []string) error {
|
|
fs := flag.NewFlagSet("kanban mint-token", flag.ContinueOnError)
|
|
dbPath := fs.String("db", "operations.db", "SQLite database path")
|
|
userID := fs.String("user", "", "owner user_id (must exist in users table)")
|
|
name := fs.String("name", "", "label for this token (e.g. PC name)")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if *userID == "" || *name == "" {
|
|
return fmt.Errorf("--user and --name required")
|
|
}
|
|
|
|
db, err := openDB(*dbPath)
|
|
if err != nil {
|
|
return fmt.Errorf("open db: %w", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
var exists int
|
|
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM users WHERE id=?`, *userID).Scan(&exists); err != nil {
|
|
return fmt.Errorf("user lookup: %w", err)
|
|
}
|
|
if exists == 0 {
|
|
return fmt.Errorf("user %q not found", *userID)
|
|
}
|
|
|
|
plaintext, tok, err := db.MintMCPToken(*userID, *name)
|
|
if err != nil {
|
|
return fmt.Errorf("mint: %w", err)
|
|
}
|
|
fmt.Printf("token id: %s\n", tok.ID)
|
|
fmt.Printf("name: %s\n", tok.Name)
|
|
fmt.Printf("created_at: %s\n", tok.CreatedAt)
|
|
fmt.Printf("\ntoken (save now, will not be shown again):\n%s\n", plaintext)
|
|
return nil
|
|
}
|