package main
import (
"context"
"errors"
"fmt"
"os"
"strconv"
"text/tabwriter"
"time"
"sourcecraft.dev/bigbes/sr-ht-core/config"
"sourcecraft.dev/bigbes/sr-ht-spec/db"
)
const tokenUsage = "usage: specsrht token create <name> | specsrht token list | specsrht token revoke <id>"
// runToken is the agent-token administration command:
//
// specsrht token create <name>
// specsrht token list
// specsrht token revoke <id>
//
// Agent tokens have no other entry point, and without one a freshly deployed
// instance cannot be written to at all: both agent write surfaces — the REST
// PUT and mcpsrv's spec_propose — refuse an anonymous caller, and the human
// path (native receive-pack) is approval rather than proposal. The alternative
// to this command is an operator hand-writing an INSERT with a sha256 hash,
// which is exactly the shape of mistake that ends with an unusable credential
// and no way to tell why.
//
// It talks to [db.Store] directly rather than going through service.New. Tokens
// touch neither git nor the index, so pulling in the repo root, the hooks and
// bleve to mint a row would only add ways for the command to fail on an
// instance whose daemon is otherwise fine.
func runToken(args []string) error {
if len(args) == 0 {
return errors.New(tokenUsage)
}
conf := config.LoadConfig()
cfg, err := validateConfig(conf)
if err != nil {
return err
}
pool, err := openDatabase(cfg.ConnectionString)
if err != nil {
return err
}
defer pool.Close()
store := db.NewStore(pool)
ctx := context.Background()
switch args[0] {
case "create":
if len(args) != 2 {
return errors.New("usage: specsrht token create <name>")
}
name := args[1]
// The plaintext exists only here: it is generated, hashed, stored as a
// hash, and printed once. Nothing writes it to the log, because a token
// in a log file is a token in a backup.
token, err := db.GenerateToken()
if err != nil {
return err
}
row, err := store.CreateAgentToken(ctx, name, db.HashToken(token))
if err != nil {
return err
}
fmt.Printf("created agent token %q (id %d)\n\n %s\n\n"+
"This is the only time the token is shown — only its hash is stored.\n"+
"Agents present it as: Authorization: Bearer <token>\n",
row.Name, row.ID, token)
return nil
case "list":
tokens, err := store.ListAgentTokens(ctx)
if err != nil {
return err
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tNAME\tCREATED\tSTATE")
for _, t := range tokens {
fmt.Fprintln(w, formatTokenRow(t))
}
return w.Flush()
case "revoke":
if len(args) != 2 {
return errors.New("usage: specsrht token revoke <id>")
}
id, err := parseTokenID(args[1])
if err != nil {
return err
}
if err := store.RevokeAgentToken(ctx, id); err != nil {
return err
}
fmt.Printf("revoked agent token %d\n", id)
return nil
default:
return fmt.Errorf("unknown subcommand %q: want create, list or revoke", args[0])
}
}
// formatTokenRow renders one token as a tab-separated line for `token list`.
// The hash is deliberately not shown: it identifies nothing an operator acts
// on, and printing a column of it would only invite treating it as the
// credential.
func formatTokenRow(t *db.AgentToken) string {
state := "active"
if !t.Active() {
state = "revoked " + t.Revoked.Format(time.RFC3339)
}
return fmt.Sprintf("%d\t%s\t%s\t%s", t.ID, t.Name, t.Created.Format(time.RFC3339), state)
}
// parseTokenID reads the id argument of `token revoke`. It rejects anything
// that is not a positive integer here rather than letting a typo become an
// UPDATE that matches no row and reports "not found", which reads like the
// token is already gone.
func parseTokenID(s string) (int, error) {
id, err := strconv.Atoi(s)
if err != nil || id <= 0 {
return 0, fmt.Errorf("token id %q is not a positive integer; `specsrht token list` shows the ids", s)
}
return id, nil
}