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

JSON API: one endpoint fronting the whole control plane

- POST /api/v1/cmd with {argv, stdin}: real argv (no tokenizer), same
  registry, same dispatcher rules as SSH; exit codes map onto HTTP
  statuses; the body is the command's JSON envelope with exit_code
  (raw-output commands like help/mr diff are wrapped as {output})
- opt-in via [api] enabled (default off): an instance that never turns
  it on has no credential-bearing HTTP surface; the route-table test
  covers both states
- bearer tokens (gb_ prefix for secret scanners) minted ONLY over SSH:
  token create/list/revoke are SSHOnly — an API token can never mint
  further credentials; scopes full|read with ReadOnly command markers
  enforced in the dispatcher; optional --ttl (30d / Go durations);
  hashes only in the DB, uniform 401 for missing/invalid/expired
- git transport refused by name on the API (SSH remains the only path)
- CLI gitbay auth token create/list/revoke passthrough
- e2e: mint over SSH, whoami/mutations/reads through the API, status
  mapping, raw-output wrapping, SSH-only refusal, read-scope write
  denial, 1s-ttl expiry, revocation, and 404 when disabled
 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(-)

diff --git a/cmd/gitbay/main.go b/cmd/gitbay/main.go
index 7c0d18b..674696a 100644
--- a/cmd/gitbay/main.go
+++ b/cmd/gitbay/main.go
@@ -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"}}),
diff --git a/e2e/api_test.go b/e2e/api_test.go
new file mode 100644
index 0000000..e7ca338
--- /dev/null
+++ b/e2e/api_test.go
@@ -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)
+	}
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 7b55c5f..6385913 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -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"`
diff --git a/internal/control/control.go b/internal/control/control.go
index 197fcd9..10c244c 100644
--- a/internal/control/control.go
+++ b/internal/control/control.go
@@ -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)
diff --git a/internal/control/identity.go b/internal/control/identity.go
index e596b40..81af9d5 100644
--- a/internal/control/identity.go
+++ b/internal/control/identity.go
@@ -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"},
diff --git a/internal/control/issue.go b/internal/control/issue.go
index 3a68752..7c4ffd3 100644
--- a/internal/control/issue.go
+++ b/internal/control/issue.go
@@ -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})
diff --git a/internal/control/mr.go b/internal/control/mr.go
index 93fe3f4..30bf402 100644
--- a/internal/control/mr.go
+++ b/internal/control/mr.go
@@ -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})
diff --git a/internal/control/org.go b/internal/control/org.go
index 427d5c7..fea2e37 100644
--- a/internal/control/org.go
+++ b/internal/control/org.go
@@ -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.
diff --git a/internal/control/repo.go b/internal/control/repo.go
index 819fae6..c7f02d6 100644
--- a/internal/control/repo.go
+++ b/internal/control/repo.go
@@ -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"},
diff --git a/internal/control/sig.go b/internal/control/sig.go
index 63d44d0..591cc6b 100644
--- a/internal/control/sig.go
+++ b/internal/control/sig.go
@@ -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 {
diff --git a/internal/control/token.go b/internal/control/token.go
new file mode 100644
index 0000000..970bf83
--- /dev/null
+++ b/internal/control/token.go
@@ -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])
+	})
+}
diff --git a/internal/httpd/api.go b/internal/httpd/api.go
new file mode 100644
index 0000000..bb79180
--- /dev/null
+++ b/internal/httpd/api.go
@@ -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,
+	})
+}
diff --git a/internal/httpd/routes.go b/internal/httpd/routes.go
index cbdd625..d7da92f 100644
--- a/internal/httpd/routes.go
+++ b/internal/httpd/routes.go
@@ -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" {
diff --git a/internal/httpd/routes_test.go b/internal/httpd/routes_test.go
index 23fe506..b0d5573 100644
--- a/internal/httpd/routes_test.go
+++ b/internal/httpd/routes_test.go
@@ -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) {
diff --git a/internal/store/migrations/0004_api_tokens.down.sql b/internal/store/migrations/0004_api_tokens.down.sql
new file mode 100644
index 0000000..1d115cc
--- /dev/null
+++ b/internal/store/migrations/0004_api_tokens.down.sql
@@ -0,0 +1 @@
+DROP TABLE api_tokens;
diff --git a/internal/store/migrations/0004_api_tokens.up.sql b/internal/store/migrations/0004_api_tokens.up.sql
new file mode 100644
index 0000000..52dddc1
--- /dev/null
+++ b/internal/store/migrations/0004_api_tokens.up.sql
@@ -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)
+);
diff --git a/internal/store/tokens.go b/internal/store/tokens.go
new file mode 100644
index 0000000..d0281fd
--- /dev/null
+++ b/internal/store/tokens.go
@@ -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
+}