krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
8ff79f104466ce76ae50fb73fd4169750b88deed
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-24T00:46:42Z
cmd/gitbay/main.go | 6 + e2e/api_test.go | 163 +++++++++++++++++++++ internal/config/config.go | 8 + internal/control/control.go | 19 ++- internal/control/identity.go | 6 +- internal/control/issue.go | 4 +- internal/control/mr.go | 6 +- internal/control/org.go | 6 +- internal/control/repo.go | 8 +- internal/control/sig.go | 4 +- internal/control/token.go | 131 +++++++++++++++++ internal/httpd/api.go | 120 +++++++++++++++ internal/httpd/routes.go | 7 + internal/httpd/routes_test.go | 20 +++ internal/store/migrations/0004_api_tokens.down.sql | 1 + internal/store/migrations/0004_api_tokens.up.sql | 11 ++ internal/store/tokens.go | 83 +++++++++++ 17 files changed, 585 insertions(+), 18 deletions(-) @@ -172,7 +172,13 @@ func authCmd() *cobra.Command { return nil }, } + tokens := group("token", "API tokens (minted over SSH, used with the JSON API)", + pass("create", "mint a token: --name <n> [--scope full|read] [--ttl 30d]", passOpts{server: []string{"token", "create"}}), + pass("list", "list API tokens", passOpts{server: []string{"token", "list"}}), + pass("revoke", "revoke a token by name", passOpts{server: []string{"token", "revoke"}}), + ) return group("auth", "identity: whoami, SSH and PGP keys", + tokens, pass("whoami", "show the authenticated account", passOpts{server: []string{"whoami"}}), group("keys", "manage SSH keys", pass("list", "list registered SSH keys", passOpts{server: []string{"keys", "list"}}), new file mode 100644 @@ -0,0 +1,163 @@ +package e2e + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" +) + +// apiCall posts one command to the JSON API. +func (i *instance) apiCall(t *testing.T, token string, argv []string, stdin string) (int, map[string]any) { + t.Helper() + body, _ := json.Marshal(map[string]any{"argv": argv, "stdin": stdin}) + req, err := http.NewRequest("POST", + fmt.Sprintf("http://127.0.0.1:%d/api/v1/cmd", i.httpPort), bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("API response not JSON (%d): %s", resp.StatusCode, raw) + } + return resp.StatusCode, out +} + +func TestJSONAPI(t *testing.T) { + inst := startInstanceWith(t, "[api]\nenabled = true\n") + aliceKey := inst.newKey(t, "alice") + inst.admin(t, "admin", "user", "create", "alice", + "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") + + // Tokens are minted over SSH, shown once. + out, errOut, code := inst.ssh(t, aliceKey, "", "token", "create", "--name", "ci", "--json") + if code != 0 { + t.Fatalf("token create: %s", errOut) + } + var env struct { + Data struct { + Token string `json:"token"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(out), &env); err != nil || !strings.HasPrefix(env.Data.Token, "gb_") { + t.Fatalf("token create output: %v %s", err, out) + } + token := env.Data.Token + + // Auth failures are uniform 401s. + if status, _ := inst.apiCall(t, "", []string{"whoami"}, ""); status != 401 { + t.Fatalf("no token: %d", status) + } + if status, _ := inst.apiCall(t, "gb_wrong", []string{"whoami"}, ""); status != 401 { + t.Fatalf("bad token: %d", status) + } + + // whoami through the API: same envelope, exit_code injected. + status, body := inst.apiCall(t, token, []string{"whoami"}, "") + if status != 200 || body["exit_code"].(float64) != 0 { + t.Fatalf("whoami: %d %v", status, body) + } + if data := body["data"].(map[string]any); data["username"] != "alice" { + t.Fatalf("whoami data: %v", body) + } + + // Mutations work: create a repo and an issue, then read it back. + if status, body = inst.apiCall(t, token, []string{"repo", "create", "alice/proj", "--private"}, ""); status != 200 { + t.Fatalf("repo create: %d %v", status, body) + } + if status, _ = inst.apiCall(t, token, []string{"issue", "create", "alice/proj", "--title", "from the api", "--file", "-"}, "body via stdin\n"); status != 200 { + t.Fatal("issue create failed") + } + status, body = inst.apiCall(t, token, []string{"issue", "show", "alice/proj", "1"}, "") + data := body["data"].(map[string]any) + if status != 200 || data["title"] != "from the api" || data["body"] != "body via stdin\n" { + t.Fatalf("issue show: %d %v", status, body) + } + + // Exit codes map to HTTP statuses. + if status, _ = inst.apiCall(t, token, []string{"issue", "show", "alice/proj", "99"}, ""); status != 404 { + t.Fatalf("missing issue: %d", status) + } + if status, _ = inst.apiCall(t, token, []string{"nonsense"}, ""); status != 400 { + t.Fatalf("unknown command: %d", status) + } + + // Raw-output commands (no envelope) are wrapped. + status, body = inst.apiCall(t, token, []string{"help"}, "") + if status != 200 || !strings.Contains(body["output"].(string), "repo create") { + t.Fatalf("help via API: %d %v", status, body) + } + + // Git transport is refused by name. + if status, _ = inst.apiCall(t, token, []string{"git-upload-pack", "alice/proj"}, ""); status != 400 { + t.Fatalf("git over API: %d", status) + } + + // Token management never works over the API: no credential minting. + status, body = inst.apiCall(t, token, []string{"token", "create", "--name", "sneaky"}, "") + if status != 403 || !strings.Contains(body["error"].(string), "only available over SSH") { + t.Fatalf("token create via API: %d %v", status, body) + } + + // Read-scoped tokens read but never write. + out, _, code = inst.ssh(t, aliceKey, "", "token", "create", "--name", "reader", "--scope", "read", "--json") + if code != 0 { + t.Fatal("read token create failed") + } + json.Unmarshal([]byte(out), &env) + readToken := env.Data.Token + if status, _ = inst.apiCall(t, readToken, []string{"issue", "list", "alice/proj"}, ""); status != 200 { + t.Fatalf("read token list: %d", status) + } + status, body = inst.apiCall(t, readToken, []string{"issue", "close", "alice/proj", "1"}, "") + if status != 403 || !strings.Contains(body["error"].(string), "read-only") { + t.Fatalf("read token write: %d %v", status, body) + } + + // Expiry: a 1-second token dies. + out, _, _ = inst.ssh(t, aliceKey, "", "token", "create", "--name", "brief", "--ttl", "1s", "--json") + json.Unmarshal([]byte(out), &env) + brief := env.Data.Token + if status, _ = inst.apiCall(t, brief, []string{"whoami"}, ""); status != 200 { + t.Fatal("fresh short-ttl token rejected") + } + time.Sleep(1100 * time.Millisecond) + if status, _ = inst.apiCall(t, brief, []string{"whoami"}, ""); status != 401 { + t.Fatal("expired token accepted") + } + + // Revocation kills a token immediately. + if _, _, code = inst.ssh(t, aliceKey, "", "token", "revoke", "ci"); code != 0 { + t.Fatal("revoke failed") + } + if status, _ = inst.apiCall(t, token, []string{"whoami"}, ""); status != 401 { + t.Fatal("revoked token accepted") + } + + // With [api] disabled (the default), the endpoint does not exist. + inst2 := startInstance(t) + req, _ := http.NewRequest("POST", fmt.Sprintf("http://127.0.0.1:%d/api/v1/cmd", inst2.httpPort), + strings.NewReader(`{"argv":["whoami"]}`)) + req.Header.Set("Authorization", "Bearer gb_x") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 404 { + t.Fatalf("API on disabled instance: %d, want 404", resp.StatusCode) + } +} @@ -19,6 +19,7 @@ type Config struct { GitDaemon GitDaemon `toml:"git_daemon"` Web Web `toml:"web"` Registration Registration `toml:"registration"` + API API `toml:"api"` Limits Limits `toml:"limits"` Mail Mail `toml:"mail"` } @@ -61,6 +62,13 @@ type Registration struct { Mode string `toml:"mode"` // closed | invite | open } +// API controls the HTTPS/JSON control-plane API (bearer tokens minted over +// SSH). Off by default: an instance that never enables it has no +// credential-bearing HTTP surface at all. +type API struct { + Enabled bool `toml:"enabled"` +} + type Limits struct { MaxPackBytes int64 `toml:"max_pack_bytes"` MaxBlobBytes int64 `toml:"max_blob_bytes"` @@ -24,12 +24,20 @@ type Ctx struct { Stdout io.Writer Stderr io.Writer JSON bool + // ViaAPI marks requests arriving over the HTTP token API. Some + // commands (token management) are SSH-only: an API token must never + // mint further credentials. + ViaAPI bool + // ReadOnly is set for read-scoped API tokens. + ReadOnly bool } type Command struct { Path []string // e.g. ["keys", "add"] Summary string ReadsStdin bool + ReadOnly bool // safe for read-scoped API tokens + SSHOnly bool // refused over the HTTP API (credential minting) Run func(c *Ctx, args []string) int } @@ -70,6 +78,12 @@ func Dispatch(c *Ctx, argv []string) int { if c.Scope != "full" { return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope) } + if c.ViaAPI && cmd.SSHOnly { + return c.fail(protocol.ExitDenied, "%s is only available over SSH", joinPath(cmd.Path)) + } + if c.ReadOnly && !cmd.ReadOnly { + return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state", joinPath(cmd.Path)) + } if c.User.Pending && !pendingAllowed(cmd.Path) { return c.fail(protocol.ExitDenied, "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)") @@ -132,8 +146,9 @@ func (c *Ctx) fail(code int, format string, args ...any) int { func init() { register(Command{ - Path: []string{"help"}, - Summary: "list available commands", + Path: []string{"help"}, + Summary: "list available commands", + ReadOnly: true, Run: func(c *Ctx, args []string) int { for _, cmd := range registry { fmt.Fprintf(c.Stdout, "%-24s %s\n", joinPath(cmd.Path), cmd.Summary) @@ -15,12 +15,14 @@ func init() { register(Command{ Path: []string{"whoami"}, Summary: "show the authenticated account", - Run: runWhoami, + ReadOnly: true, + Run: runWhoami, }) register(Command{ Path: []string{"keys", "list"}, Summary: "list registered SSH keys", - Run: runKeysList, + ReadOnly: true, + Run: runKeysList, }) register(Command{ Path: []string{"keys", "add"}, @@ -19,9 +19,9 @@ func init() { Summary: "open an issue: issue create <owner/name> --title <t> [--body <b> | --file -]", ReadsStdin: true, Run: runIssueCreate}) register(Command{Path: []string{"issue", "list"}, - Summary: "list issues: issue list <owner/name> [--state open|closed|all]", Run: runIssueList}) + Summary: "list issues: issue list <owner/name> [--state open|closed|all]", ReadOnly: true, Run: runIssueList}) register(Command{Path: []string{"issue", "show"}, - Summary: "show an issue with comments: issue show <owner/name> <n>", Run: runIssueShow}) + Summary: "show an issue with comments: issue show <owner/name> <n>", ReadOnly: true, Run: runIssueShow}) register(Command{Path: []string{"issue", "comment"}, Summary: "comment: issue comment <owner/name> <n> [--message <m> | --file -]", ReadsStdin: true, Run: runIssueComment}) @@ -22,11 +22,11 @@ func init() { Summary: "open a merge request: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -]", ReadsStdin: true, Run: runMRCreate}) register(Command{Path: []string{"mr", "list"}, - Summary: "list merge requests: mr list <owner/name> [--state open|merged|closed|source_gone|all]", Run: runMRList}) + Summary: "list merge requests: mr list <owner/name> [--state open|merged|closed|source_gone|all]", ReadOnly: true, Run: runMRList}) register(Command{Path: []string{"mr", "show"}, - Summary: "show a merge request: mr show <owner/name> <n>", Run: runMRShow}) + Summary: "show a merge request: mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow}) register(Command{Path: []string{"mr", "diff"}, - Summary: "show the diff: mr diff <owner/name> <n>", Run: runMRDiff}) + Summary: "show the diff: mr diff <owner/name> <n>", ReadOnly: true, Run: runMRDiff}) register(Command{Path: []string{"mr", "comment"}, Summary: "comment: mr comment <owner/name> <n> [--message <m> | --file -]", ReadsStdin: true, Run: runMRComment}) @@ -14,9 +14,9 @@ func init() { register(Command{Path: []string{"org", "create"}, Summary: "create an organization (you become its first admin): org create <name>", Run: runOrgCreate}) register(Command{Path: []string{"org", "list"}, - Summary: "list organizations you belong to", Run: runOrgList}) + Summary: "list organizations you belong to", ReadOnly: true, Run: runOrgList}) register(Command{Path: []string{"org", "show"}, - Summary: "show an organization and its members: org show <name>", Run: runOrgShow}) + Summary: "show an organization and its members: org show <name>", ReadOnly: true, Run: runOrgShow}) register(Command{Path: []string{"org", "delete"}, Summary: "delete an empty organization: org delete <name> --yes", Run: runOrgDelete}) register(Command{Path: []string{"org", "members", "add"}, @@ -24,7 +24,7 @@ func init() { register(Command{Path: []string{"org", "members", "remove"}, Summary: "remove a member: org members remove <org> <user>", Run: runOrgMembersRemove}) register(Command{Path: []string{"org", "members", "list"}, - Summary: "list members: org members list <org>", Run: runOrgMembersList}) + Summary: "list members: org members list <org>", ReadOnly: true, Run: runOrgMembersList}) } // orgAdmin loads an org and requires the caller to be one of its admins. @@ -27,9 +27,9 @@ func init() { register(Command{Path: []string{"repo", "create"}, Summary: "create a repository: repo create <owner/name> [--private]", Run: runRepoCreate}) register(Command{Path: []string{"repo", "list"}, - Summary: "list repositories you own or can access", Run: runRepoList}) + Summary: "list repositories you own or can access", ReadOnly: true, Run: runRepoList}) register(Command{Path: []string{"repo", "show"}, - Summary: "show repository details: repo show <owner/name>", Run: runRepoShow}) + Summary: "show repository details: repo show <owner/name>", ReadOnly: true, Run: runRepoShow}) register(Command{Path: []string{"repo", "delete"}, Summary: "delete a repository: repo delete <owner/name> --yes", Run: runRepoDelete}) register(Command{Path: []string{"repo", "access", "grant"}, @@ -37,9 +37,9 @@ func init() { register(Command{Path: []string{"repo", "access", "revoke"}, Summary: "revoke access: repo access revoke <owner/name> <user>", Run: runAccessRevoke}) register(Command{Path: []string{"repo", "access", "list"}, - Summary: "list access grants: repo access list <owner/name>", Run: runAccessList}) + Summary: "list access grants: repo access list <owner/name>", ReadOnly: true, Run: runAccessList}) register(Command{Path: []string{"repo", "settings", "show"}, - Summary: "show settings: repo settings show <owner/name>", Run: runSettingsShow}) + Summary: "show settings: repo settings show <owner/name>", ReadOnly: true, Run: runSettingsShow}) register(Command{Path: []string{"repo", "settings", "protect"}, Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect}) register(Command{Path: []string{"repo", "settings", "unprotect"}, @@ -19,11 +19,11 @@ func init() { register(Command{Path: []string{"pgp", "add"}, Summary: "register an OpenPGP public key (armored, on stdin)", ReadsStdin: true, Run: runPGPAdd}) register(Command{Path: []string{"pgp", "list"}, - Summary: "list registered OpenPGP keys", Run: runPGPList}) + Summary: "list registered OpenPGP keys", ReadOnly: true, Run: runPGPList}) register(Command{Path: []string{"pgp", "remove"}, Summary: "remove an OpenPGP key by fingerprint", Run: runPGPRemove}) register(Command{Path: []string{"repo", "log"}, - Summary: "commit log with signature states: repo log <owner/name> [--limit n]", Run: runRepoLog}) + Summary: "commit log with signature states: repo log <owner/name> [--limit n]", ReadOnly: true, Run: runRepoLog}) } func runPGPAdd(c *Ctx, args []string) int { new file mode 100644 @@ -0,0 +1,131 @@ +package control + +import ( + "errors" + "fmt" + "io" + "strconv" + "strings" + "time" + + "gitbay.org/gitbay/internal/protocol" + "gitbay.org/gitbay/internal/store" +) + +func init() { + register(Command{Path: []string{"token", "create"}, + Summary: "mint an API token (shown once): token create --name <n> [--scope full|read] [--ttl 30d|720h]", + SSHOnly: true, Run: runTokenCreate}) + register(Command{Path: []string{"token", "list"}, + Summary: "list API tokens", ReadOnly: true, SSHOnly: true, Run: runTokenList}) + register(Command{Path: []string{"token", "revoke"}, + Summary: "revoke an API token by name: token revoke <name>", + SSHOnly: true, Run: runTokenRevoke}) +} + +// parseTTL accepts Go durations plus a day suffix ("30d"). +func parseTTL(s string) (time.Duration, error) { + if days, ok := strings.CutSuffix(s, "d"); ok { + n, err := strconv.Atoi(days) + if err != nil || n < 1 { + return 0, fmt.Errorf("bad ttl %q", s) + } + return time.Duration(n) * 24 * time.Hour, nil + } + return time.ParseDuration(s) +} + +func runTokenCreate(c *Ctx, args []string) int { + name, scope, ttl := "", "full", "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--name", "--scope", "--ttl": + if i+1 >= len(args) { + return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) + } + switch args[i] { + case "--name": + name = args[i+1] + case "--scope": + scope = args[i+1] + case "--ttl": + ttl = args[i+1] + } + i++ + default: + return c.fail(protocol.ExitUsage, "usage: token create --name <n> [--scope full|read] [--ttl 30d]") + } + } + if name == "" || (scope != "full" && scope != "read") { + return c.fail(protocol.ExitUsage, "usage: token create --name <n> [--scope full|read] [--ttl 30d]") + } + var expires *time.Time + if ttl != "" { + d, err := parseTTL(ttl) + if err != nil { + return c.fail(protocol.ExitUsage, "%v", err) + } + t := time.Now().Add(d) + expires = &t + } + raw, _, err := store.NewToken() + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + // The gb_ prefix makes leaked tokens findable by secret scanners. + token := "gb_" + raw + if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires); err != nil { + return c.fail(protocol.ExitUsage, "%v", err) + } + type out struct { + Name string `json:"name"` + Scope string `json:"scope"` + Token string `json:"token"` + } + d := out{name, scope, token} + return c.emit(d, func(w io.Writer) { + fmt.Fprintf(w, "token %q (%s) — shown once, store it now:\n%s\n", d.Name, d.Scope, d.Token) + }) +} + +func runTokenList(c *Ctx, args []string) int { + tokens, err := c.Store.ListAPITokens(c.User.ID) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + type out struct { + Name string `json:"name"` + Scope string `json:"scope"` + CreatedAt string `json:"created_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + } + var ds []out + for _, t := range tokens { + ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt}) + } + return c.emit(ds, func(w io.Writer) { + for _, d := range ds { + exp := "never expires" + if d.ExpiresAt != nil { + exp = "expires " + d.ExpiresAt.UTC().Format(time.RFC3339) + } + fmt.Fprintf(w, "%s\t%s\t%s\n", d.Name, d.Scope, exp) + } + }) +} + +func runTokenRevoke(c *Ctx, args []string) int { + if len(args) != 1 { + return c.fail(protocol.ExitUsage, "usage: token revoke <name>") + } + if err := c.Store.RevokeAPIToken(c.User.ID, args[0]); err != nil { + if errors.Is(err, store.ErrNotFound) { + return c.fail(protocol.ExitNotFound, "no token named %q", args[0]) + } + return c.fail(protocol.ExitFailure, "%v", err) + } + return c.emit(map[string]string{"revoked": args[0]}, func(w io.Writer) { + fmt.Fprintf(w, "revoked %s\n", args[0]) + }) +} new file mode 100644 @@ -0,0 +1,120 @@ +package httpd + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "gitbay.org/gitbay/internal/control" + "gitbay.org/gitbay/internal/protocol" + "gitbay.org/gitbay/internal/store" +) + +// apiRequest is the wire form of one command invocation. argv is real +// argv — no shell, no tokenizer, no quoting rules. +type apiRequest struct { + Argv []string `json:"argv"` + Stdin string `json:"stdin,omitempty"` +} + +const maxAPIBody = 1 << 20 + +// apiCmd fronts the same control-command registry the SSH dispatcher uses: +// every command, current and future, is reachable here with identical +// semantics. Exit codes map onto HTTP statuses; the body is the command's +// JSON envelope with exit_code added. +func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) { + user, scope, ok := s.apiAuth(w, r) + if !ok { + return + } + + var req apiRequest + if err := json.NewDecoder(io.LimitReader(r.Body, maxAPIBody)).Decode(&req); err != nil { + apiError(w, http.StatusBadRequest, "body must be JSON: {\"argv\": [...], \"stdin\": \"...\"}") + return + } + if len(req.Argv) == 0 { + apiError(w, http.StatusBadRequest, "argv is required") + return + } + switch req.Argv[0] { + case "git-upload-pack", "git-receive-pack", "git-upload-archive": + apiError(w, http.StatusBadRequest, "git transport does not run over the JSON API; use git with an SSH remote") + return + } + + var stdout, stderr bytes.Buffer + ctx := &control.Ctx{ + User: user, + Scope: "full", // key scopes are an SSH concept; token scope is below + Store: s.st, + Cfg: s.cfg, + Stdin: strings.NewReader(req.Stdin), + Stdout: &stdout, + Stderr: &stderr, + JSON: true, + ViaAPI: true, + ReadOnly: scope == "read", + } + code := control.Dispatch(ctx, req.Argv) + + status := map[int]int{ + protocol.ExitOK: http.StatusOK, + protocol.ExitUsage: http.StatusBadRequest, + protocol.ExitNotFound: http.StatusNotFound, + protocol.ExitDenied: http.StatusForbidden, + }[code] + if status == 0 { + status = http.StatusInternalServerError + } + + // Commands normally emit exactly one JSON envelope; inject exit_code. + // A few (mr diff, help) write raw text instead — wrap those. + var body map[string]any + if err := json.Unmarshal(stdout.Bytes(), &body); err != nil || body == nil { + body = map[string]any{ + "protocol_version": protocol.Version, + "output": stdout.String(), + } + } + body["exit_code"] = code + if msg := strings.TrimSpace(stderr.String()); msg != "" { + body["stderr"] = msg + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(body) +} + +// apiAuth resolves the bearer token; failures are uniform 401s. +func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, string, bool) { + token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok || token == "" { + w.Header().Set("WWW-Authenticate", `Bearer realm="gitbay api"`) + apiError(w, http.StatusUnauthorized, "missing bearer token; mint one over SSH: token create --name <n>") + return store.User{}, "", false + } + user, scope, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token))) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + apiError(w, http.StatusUnauthorized, "invalid or expired token") + return store.User{}, "", false + } + apiError(w, http.StatusInternalServerError, "internal error") + return store.User{}, "", false + } + return user, scope, true +} + +func apiError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]any{ + "protocol_version": protocol.Version, + "error": msg, + }) +} @@ -43,6 +43,13 @@ func (s *Server) Routes() []Route { Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/{n}", Handler: s.mr}, ) + // The JSON API is its own opt-in surface, independent of web.mode. + if s.cfg.API.Enabled { + routes = append(routes, + Route{Method: "POST", Pattern: "/api/v1/cmd", Mutating: true, Handler: s.apiCmd}, + ) + } + // Account-mode routes exist only when web.mode = "accounts". In // view_only they are never registered — the structural guarantee. if s.cfg.Web.Mode == "accounts" { @@ -33,6 +33,26 @@ func TestViewOnlyHasNoMutatingRoutes(t *testing.T) { } } +// TestAPIRouteGating: the API route exists only when [api] enabled = true. +func TestAPIRouteGating(t *testing.T) { + has := func(cfg config.Config) bool { + for _, r := range New(cfg, nil).Routes() { + if r.Pattern == "/api/v1/cmd" { + return true + } + } + return false + } + if has(config.Default()) { + t.Fatal("API route present with api disabled (the default)") + } + cfg := config.Default() + cfg.API.Enabled = true + if !has(cfg) { + t.Fatal("API route missing with api enabled") + } +} + // TestAccountsModeHasLoginRoute is the positive counterpart: switching the // mode on registers the session routes. func TestAccountsModeHasLoginRoute(t *testing.T) { new file mode 100644 @@ -0,0 +1 @@ +DROP TABLE api_tokens; new file mode 100644 @@ -0,0 +1,11 @@ +CREATE TABLE api_tokens ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + scope TEXT NOT NULL DEFAULT 'full' CHECK (scope IN ('full','read')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + expires_at TEXT, + last_used_at TEXT, + UNIQUE (user_id, name) +); new file mode 100644 @@ -0,0 +1,83 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "time" +) + +type APIToken struct { + Name string + Scope string + CreatedAt string + ExpiresAt *time.Time + LastUsedAt *time.Time +} + +// CreateAPIToken stores a token hash; expires nil means no expiry. +func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time) error { + var exp any + if expires != nil { + exp = fmtTime(*expires) + } + _, err := s.DB.Exec( + "INSERT INTO api_tokens (user_id, name, token_hash, scope, expires_at) VALUES (?, ?, ?, ?, ?)", + userID, name, tokenHash, scope, exp) + if isUniqueErr(err) { + return fmt.Errorf("you already have a token named %q", name) + } + return err +} + +// APITokenUser resolves a presented token to its user and scope; expired and +// unknown tokens fail identically. +func (s *Store) APITokenUser(tokenHash string) (User, string, error) { + var userID int64 + var scope string + err := s.DB.QueryRow(` + SELECT user_id, scope FROM api_tokens + WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > ?)`, + tokenHash, fmtTime(time.Now())).Scan(&userID, &scope) + if errors.Is(err, sql.ErrNoRows) { + return User{}, "", ErrNotFound + } + if err != nil { + return User{}, "", err + } + s.DB.Exec("UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE token_hash = ?", tokenHash) + u, err := s.UserByID(userID) + return u, scope, err +} + +func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) { + rows, err := s.DB.Query( + "SELECT name, scope, created_at, expires_at, last_used_at FROM api_tokens WHERE user_id = ? ORDER BY name", userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []APIToken + for rows.Next() { + var t APIToken + var exp, used sql.NullString + if err := rows.Scan(&t.Name, &t.Scope, &t.CreatedAt, &exp, &used); err != nil { + return nil, err + } + t.ExpiresAt = parseTime(exp) + t.LastUsedAt = parseTime(used) + out = append(out, t) + } + return out, rows.Err() +} + +func (s *Store) RevokeAPIToken(userID int64, name string) error { + res, err := s.DB.Exec("DELETE FROM api_tokens WHERE user_id = ? AND name = ?", userID, name) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +}