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 | specsrht token list | specsrht token revoke " // runToken is the agent-token administration command: // // specsrht token create // specsrht token list // specsrht token revoke // // 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 := 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 \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, 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 }