package store import ( "crypto/sha256" "database/sql" "encoding/hex" "fmt" "strings" "time" ) // Operator access tokens (hub v0.152.0, `05` ยง17). A token is a scoped, expiring stand-in for the operator password, // for scripts (Claude Code's floor step first). The plain token is shown ONCE at creation and never stored: the row // keeps only its unkeyed SHA-256. A slow hash is not needed โ€” the token is 32 random bytes, so there is nothing to // guess and nothing a dictionary can find; the hash only stops a leaked database copy from being replayed as a token. // Pinned by TestAPIToken_StoredRowHasNoPlainToken. // APITokenPrefix starts every operator access token. The web surface accepts it; the box API refuses it. const APITokenPrefix = "fhp_" // APIToken is one access-token row. The plain token is never a field here. type APIToken struct { ID int64 Name string Scopes []string // ":read" / ":write" โ€” validated by the web package's scope list CreatedAt time.Time CreatedBy string ExpiresAt time.Time LastUsedAt *time.Time LastUsedFrom string RevokedAt *time.Time } // Expired reports whether the token's expiry has passed at now. func (t *APIToken) Expired(now time.Time) bool { return !now.Before(t.ExpiresAt) } // HashAPIToken is the stored lookup form of a plain token. func HashAPIToken(plain string) string { sum := sha256.Sum256([]byte(plain)) return hex.EncodeToString(sum[:]) } func (s *Store) migrateAPITokens() error { _, err := s.db.Exec(` CREATE TABLE IF NOT EXISTS api_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, scopes TEXT NOT NULL, created_at DATETIME NOT NULL, created_by TEXT NOT NULL DEFAULT '', expires_at DATETIME NOT NULL, last_used_at DATETIME, last_used_from TEXT NOT NULL DEFAULT '', revoked_at DATETIME );`) return err } // CreateAPIToken stores a new token by its hash. The caller generated plain and shows it once. func (s *Store) CreateAPIToken(name, plain string, scopes []string, createdBy string, expiresAt time.Time) (int64, error) { name = strings.TrimSpace(name) if name == "" { return 0, fmt.Errorf("token name is empty") } if len(scopes) == 0 { return 0, fmt.Errorf("token has no scope") } res, err := s.db.Exec(`INSERT INTO api_tokens (name, token_hash, scopes, created_at, created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?)`, name, HashAPIToken(plain), strings.Join(scopes, ","), time.Now().UTC().Format(time.RFC3339), createdBy, expiresAt.UTC().Format(time.RFC3339)) if err != nil { return 0, err } return res.LastInsertId() } const apiTokenCols = `id, name, scopes, created_at, created_by, expires_at, last_used_at, last_used_from, revoked_at` func scanAPIToken(sc interface{ Scan(...any) error }) (*APIToken, error) { var t APIToken var scopes, created, expires string var lastUsed, revoked sql.NullString if err := sc.Scan(&t.ID, &t.Name, &scopes, &created, &t.CreatedBy, &expires, &lastUsed, &t.LastUsedFrom, &revoked); err != nil { return nil, err } if scopes != "" { t.Scopes = strings.Split(scopes, ",") } t.CreatedAt = parseSQLiteTime(created) t.ExpiresAt = parseSQLiteTime(expires) if lastUsed.Valid && lastUsed.String != "" { v := parseSQLiteTime(lastUsed.String) t.LastUsedAt = &v } if revoked.Valid && revoked.String != "" { v := parseSQLiteTime(revoked.String) t.RevokedAt = &v } return &t, nil } // GetAPITokenByPlain finds a token by its plain value (hashed here). nil, nil = no such token. func (s *Store) GetAPITokenByPlain(plain string) (*APIToken, error) { t, err := scanAPIToken(s.db.QueryRow(`SELECT `+apiTokenCols+` FROM api_tokens WHERE token_hash = ?`, HashAPIToken(plain))) if err == sql.ErrNoRows { return nil, nil } return t, err } // ListAPITokens returns every token, newest first (revoked ones included โ€” the list is the audit trail). func (s *Store) ListAPITokens() ([]*APIToken, error) { rows, err := s.db.Query(`SELECT ` + apiTokenCols + ` FROM api_tokens ORDER BY id DESC`) if err != nil { return nil, err } defer rows.Close() var out []*APIToken for rows.Next() { t, err := scanAPIToken(rows) if err != nil { return nil, err } out = append(out, t) } return out, rows.Err() } // RevokeAPIToken marks a token revoked. Revoking twice is a no-op; an unknown id is an error. func (s *Store) RevokeAPIToken(id int64) error { res, err := s.db.Exec(`UPDATE api_tokens SET revoked_at = COALESCE(revoked_at, ?) WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return fmt.Errorf("no token with id %d", id) } return nil } // TouchAPIToken records a use. Best effort: a failure here never refuses the request. func (s *Store) TouchAPIToken(id int64, from string) error { _, err := s.db.Exec(`UPDATE api_tokens SET last_used_at = ?, last_used_from = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), from, id) return err }