~bigbes/sr-ht-dolt

ce06498a3dd0d801f334f0c60345ab2174a618fb — Eugene Blikh 30 days ago c6172db
feat: auto-provision companion Dolt DBs from git.sr.ht pushes

Add a service-to-service path so pushing a git.sr.ht repo creates a matching
Dolt database at ~owner/name, ready before the user's first `dolt push`.

- web: POST /internal/repos, guarded by internal-IP + network-key `Internal`
  auth (not the browser cookie/CSRF). Resolves/mirrors the owner via
  auth.LookupUser, then CreateRepo + InitStore, rolling back the row if the
  store init fails. Idempotent: an existing companion returns 200, a fresh one
  201 — safe to call on every push.
- cmd/dolt-git-hook: the git.sr.ht post-update-script. Delegates every hook
  stage to the stock /usr/bin/git.sr.ht-update-hook unchanged (argv[0], stdin,
  env, exit code preserved; fail-closed if the delegate is missing), then on
  post-update POSTs the companion create and prints a one-time clone notice.
  Best-effort: never fails a push, degrades to a warning on any misconfig.

Tests cover the endpoint (provision/idempotent/rollback/bad-input) and the hook
(signed request round-trips through the guard's decryption, notice only on 201).
A cmd/dolt-git-hook/main.go => cmd/dolt-git-hook/main.go +234 -0
@@ 0,0 1,234 @@
// Command dolt-git-hook is git.sr.ht's post-update-script, replacing the stock
// /usr/bin/git.sr.ht-update-hook in [git.sr.ht]post-update-script. git.sr.ht
// symlinks the configured script as all four repo hooks (pre-receive, update,
// post-update, post-receive); this binary is that script.
//
// It does two things:
//
//  1. Delegates every invocation to the stock hook, unchanged — same argv[0]
//     (so the stock binary's os.Args[0] dispatch still fires), same stdin, env,
//     working directory and exit code. Build submission, webhook delivery, ACL
//     enforcement and the stock autocreate notice all keep working.
//
//  2. On the post-update stage only, provisions a companion Dolt database at
//     ~owner/name via dolt.sr.ht's internal create endpoint, so a matching Dolt
//     DB exists before the user's first `dolt push`, and prints a one-time
//     terminal notice when it is first created. This step is strictly
//     best-effort: it never changes the delegate's exit code and never fails a
//     push (post-update runs after refs are already updated).
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"time"

	"sourcecraft.dev/bigbes/sr-ht-core/config"
	"sourcecraft.dev/bigbes/sr-ht-core/crypto"
)

// defaultDelegate is the stock git.sr.ht hook this wrapper wraps. The apk still
// installs it here; only the [git.sr.ht]post-update-script symlink target moves
// to this binary. Overridable via env for testing.
const defaultDelegate = "/usr/bin/git.sr.ht-update-hook"

// provisionTimeout bounds the internal create call so a slow or down dolt.sr.ht
// never adds more than this to a push.
const provisionTimeout = 5 * time.Second

func main() {
	code := runDelegate()

	// filepath.Base("hooks/post-update") == "post-update".
	if filepath.Base(os.Args[0]) == "post-update" {
		provisionDolt()
	}

	os.Exit(code)
}

// runDelegate execs the stock hook with this process's argv, stdio, env and
// working directory, and returns its exit code. If the delegate cannot be
// started at all (e.g. missing binary) it fails closed with a non-zero code:
// the stock hook is where ACL enforcement lives, so a push must not proceed
// without it.
func runDelegate() int {
	delegate := os.Getenv("DOLT_GIT_HOOK_DELEGATE")
	if delegate == "" {
		delegate = defaultDelegate
	}

	cmd := exec.Command(delegate)
	cmd.Args = os.Args // preserve argv[0] = "hooks/<stage>" for the stock dispatch
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	cmd.Env = os.Environ()

	err := cmd.Run()
	if err == nil {
		return 0
	}
	var ee *exec.ExitError
	if errors.As(err, &ee) {
		return ee.ExitCode()
	}
	fmt.Fprintf(os.Stderr, "dolt-git-hook: cannot run %s: %v\n", delegate, err)
	return 1
}

// pushContext is the subset of git.sr.ht's SRHT_PUSH_CTX we need: the repo's
// owner, name and visibility. git.sr.ht-shell sets this env var before exec'ing
// git-receive-pack, so it is inherited by every hook.
type pushContext struct {
	Repo struct {
		Name       string `json:"name"`
		OwnerName  string `json:"owner_name"`
		Visibility string `json:"visibility"`
	} `json:"repo"`
}

// internalAuth mirrors core-go's client.InternalAuth wire shape. crypto.Encrypt
// seals it with the shared [sr.ht]network-key; dolt.sr.ht's internalAuthGuard
// decrypts and trusts it.
type internalAuth struct {
	Name     string `json:"name,omitempty"`
	ClientID string `json:"client_id"`
	NodeID   string `json:"node_id"`
}

// provisionDolt asks dolt.sr.ht to create the companion database for the pushed
// repo. Every failure mode is swallowed (logged to stderr at most): this is a
// convenience, not part of the push contract.
func provisionDolt() {
	// A bug here must never escape into the push; recover defensively (config
	// loading log.Fatalf's are handled by only running post-update, where the
	// exit code is already irrelevant to push success).
	defer func() { _ = recover() }()

	raw := os.Getenv("SRHT_PUSH_CTX")
	if raw == "" {
		return // push not routed through git.sr.ht-shell; nothing to do
	}
	var pc pushContext
	if err := json.Unmarshal([]byte(raw), &pc); err != nil {
		return
	}
	if pc.Repo.Name == "" || pc.Repo.OwnerName == "" {
		return
	}

	conf := config.LoadConfig()

	// crypto.InitCrypto log.Fatalf's (os.Exit) on a missing key, which recover
	// cannot catch — check the keys ourselves first so a misconfigured instance
	// degrades to a skipped companion, never a hard-exiting hook.
	if _, ok := conf.Get("sr.ht", "network-key"); !ok {
		fmt.Fprintln(os.Stderr, "dolt-git-hook: [sr.ht]network-key not set; skipping companion provisioning")
		return
	}
	if _, ok := conf.Get("webhooks", "private-key"); !ok {
		fmt.Fprintln(os.Stderr, "dolt-git-hook: [webhooks]private-key not set; skipping companion provisioning")
		return
	}

	origin, ok := conf.Get("dolt.sr.ht", "internal-origin")
	if !ok || origin == "" {
		fmt.Fprintln(os.Stderr, "dolt-git-hook: [dolt.sr.ht]internal-origin not set; skipping companion provisioning")
		return
	}

	crypto.InitCrypto(conf)

	createCompanion(os.Stderr, origin, pc)
}

// createCompanion POSTs the internal create request for pc to origin, signing it
// with the shared network-key (crypto must already be initialized), and reports
// progress to out: the one-time notice on a fresh 201, silence on an existing
// 200, a warning on anything else. It is separated from provisionDolt so it can
// be integration-tested against an httptest server without config-file loading.
func createCompanion(out io.Writer, origin string, pc pushContext) {
	reqBody := map[string]string{
		"owner": pc.Repo.OwnerName,
		"name":  pc.Repo.Name,
	}
	if v := normalizeVisibility(pc.Repo.Visibility); v != "" {
		reqBody["visibility"] = v
	}
	body, _ := json.Marshal(reqBody)

	authBlob, _ := json.Marshal(internalAuth{
		Name:     pc.Repo.OwnerName,
		ClientID: "git.sr.ht",
		NodeID:   "dolt-git-hook",
	})

	req, err := http.NewRequest("POST",
		strings.TrimRight(origin, "/")+"/internal/repos", bytes.NewReader(body))
	if err != nil {
		return
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Internal "+string(crypto.Encrypt(authBlob)))

	client := &http.Client{Timeout: provisionTimeout}
	resp, err := client.Do(req)
	if err != nil {
		fmt.Fprintf(out, "dolt-git-hook: dolt.sr.ht unreachable: %v\n", err)
		return
	}
	defer resp.Body.Close()

	switch resp.StatusCode {
	case http.StatusCreated:
		var r struct {
			URL string `json:"url"`
		}
		_ = json.NewDecoder(resp.Body).Decode(&r)
		printNotice(out, pc.Repo.OwnerName, pc.Repo.Name, r.URL)
	case http.StatusOK:
		// Companion already existed; stay quiet so only the first push announces.
	default:
		msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		fmt.Fprintf(out, "dolt-git-hook: companion provisioning failed (%d): %s\n",
			resp.StatusCode, strings.TrimSpace(string(msg)))
	}
}

// normalizeVisibility maps a git.sr.ht visibility to the dolt.sr.ht enum,
// returning "" (let the endpoint default to PRIVATE) for anything unrecognized.
func normalizeVisibility(v string) string {
	switch strings.ToUpper(strings.TrimSpace(v)) {
	case "PUBLIC":
		return "PUBLIC"
	case "UNLISTED":
		return "UNLISTED"
	case "PRIVATE":
		return "PRIVATE"
	default:
		return ""
	}
}

// printNotice writes the one-time companion-created notice to out (os.Stderr in
// production), which git relays to the pushing client's terminal — the same
// stream and stage git.sr.ht uses for its own autocreate notice.
func printNotice(out io.Writer, owner, name, url string) {
	if url == "" {
		url = fmt.Sprintf("(~%s/%s)", owner, name)
	}
	fmt.Fprintf(out, "\n\t\033[93mNOTICE\033[0m\n"+
		"\tA Dolt database companion has been created for ~%s/%s:\n\n"+
		"\t    dolt clone %s\n"+
		"\t    web:   %s\n\n",
		owner, name, url, url)
}

A cmd/dolt-git-hook/main_test.go => cmd/dolt-git-hook/main_test.go +142 -0
@@ 0,0 1,142 @@
package main

import (
	"bytes"
	"crypto/ed25519"
	"encoding/base64"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/fernet/fernet-go"
	"github.com/vaughan0/go-ini"

	"sourcecraft.dev/bigbes/sr-ht-core/crypto"
)

// initTestCrypto installs a random network key + webhooks seed into the shared
// crypto globals, mirroring the sr-ht-core test pattern, so createCompanion's
// crypto.Encrypt and the test server's DecryptWithExpiration share a keyset.
func initTestCrypto(t *testing.T) {
	t.Helper()
	var fk fernet.Key
	if err := fk.Generate(); err != nil {
		t.Fatalf("fernet generate: %v", err)
	}
	seed := make([]byte, ed25519.SeedSize)
	for i := range seed {
		seed[i] = byte(i + 1)
	}
	crypto.InitCrypto(ini.File{
		"sr.ht":    ini.Section{"network-key": fk.Encode()},
		"webhooks": ini.Section{"private-key": base64.StdEncoding.EncodeToString(seed)},
	})
}

func samplePush() pushContext {
	var pc pushContext
	pc.Repo.Name = "widgets"
	pc.Repo.OwnerName = "alice"
	pc.Repo.Visibility = "private"
	return pc
}

// decodeInternalAuth verifies an incoming "Internal <token>" header the same way
// dolt.sr.ht's internalAuthGuard does and returns the decoded claims.
func decodeInternalAuth(t *testing.T, r *http.Request) internalAuth {
	t.Helper()
	parts := strings.SplitN(r.Header.Get("Authorization"), " ", 2)
	if len(parts) != 2 || !strings.EqualFold(parts[0], "internal") {
		t.Fatalf("missing/invalid Internal auth header: %q", r.Header.Get("Authorization"))
	}
	payload := crypto.DecryptWithExpiration([]byte(parts[1]), 30*time.Second)
	if payload == nil {
		t.Fatalf("auth token did not decrypt (wrong key or expired)")
	}
	var ia internalAuth
	if err := json.Unmarshal(payload, &ia); err != nil {
		t.Fatalf("auth payload not JSON: %v", err)
	}
	return ia
}

func TestCreateCompanionSignsAndAnnouncesOnCreate(t *testing.T) {
	initTestCrypto(t)

	var gotBody map[string]string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/internal/repos" || r.Method != http.MethodPost {
			t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
		}
		ia := decodeInternalAuth(t, r)
		if ia.ClientID != "git.sr.ht" || ia.NodeID != "dolt-git-hook" {
			t.Errorf("unexpected auth claims: %+v", ia)
		}
		raw, _ := io.ReadAll(r.Body)
		_ = json.Unmarshal(raw, &gotBody)
		w.WriteHeader(http.StatusCreated)
		_, _ = w.Write([]byte(`{"url":"https://dolt.example/~alice/widgets","created":true}`))
	}))
	defer srv.Close()

	var out bytes.Buffer
	createCompanion(&out, srv.URL, samplePush())

	if gotBody["owner"] != "alice" || gotBody["name"] != "widgets" {
		t.Fatalf("unexpected request body: %+v", gotBody)
	}
	if gotBody["visibility"] != "PRIVATE" {
		t.Fatalf("visibility not normalized to PRIVATE: %q", gotBody["visibility"])
	}
	if !strings.Contains(out.String(), "NOTICE") ||
		!strings.Contains(out.String(), "dolt clone https://dolt.example/~alice/widgets") {
		t.Fatalf("expected clone notice, got: %q", out.String())
	}
}

func TestCreateCompanionQuietWhenExists(t *testing.T) {
	initTestCrypto(t)
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		decodeInternalAuth(t, r) // still must be authenticated
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte(`{"url":"https://dolt.example/~alice/widgets","created":false}`))
	}))
	defer srv.Close()

	var out bytes.Buffer
	createCompanion(&out, srv.URL, samplePush())
	if out.Len() != 0 {
		t.Fatalf("expected no output for existing companion, got: %q", out.String())
	}
}

func TestCreateCompanionWarnsOnError(t *testing.T) {
	initTestCrypto(t)
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusInternalServerError)
		_, _ = w.Write([]byte("boom"))
	}))
	defer srv.Close()

	var out bytes.Buffer
	createCompanion(&out, srv.URL, samplePush())
	if !strings.Contains(out.String(), "provisioning failed (500)") {
		t.Fatalf("expected failure warning, got: %q", out.String())
	}
}

func TestNormalizeVisibility(t *testing.T) {
	cases := map[string]string{
		"private": "PRIVATE", "PUBLIC": "PUBLIC", "Unlisted": "UNLISTED",
		"": "", "bogus": "",
	}
	for in, want := range cases {
		if got := normalizeVisibility(in); got != want {
			t.Errorf("normalizeVisibility(%q) = %q, want %q", in, got, want)
		}
	}
}

A web/handlers_internal.go => web/handlers_internal.go +204 -0
@@ 0,0 1,204 @@
package web

import (
	"encoding/json"
	"errors"
	"fmt"
	"net"
	"net/http"
	"strings"
	"time"

	"sourcecraft.dev/bigbes/sr-ht-core/config"
	"sourcecraft.dev/bigbes/sr-ht-core/crypto"

	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
	"sourcecraft.dev/bigbes/sr-ht-dolt/db"
)

// This file implements the service-to-service create endpoint that lets other
// SourceHut services provision a companion Dolt database. Its first (and only)
// caller is git.sr.ht's post-update hook, which POSTs here whenever a git repo
// is pushed so a matching Dolt DB exists at ~owner/name before the user's first
// `dolt push`. It is NOT a browser route: it carries no CSRF token and no
// unified-login cookie, and is guarded by internalAuthGuard instead of the
// cookie auth the rest of web/ uses.

// internalCreateRequest is the JSON body POSTed to /internal/repos. Owner is a
// SourceHut username (with or without the leading "~"); Name is the database
// name. Visibility is optional and defaults to PRIVATE — the safe default for
// an auto-provisioned companion the user has not explicitly published.
type internalCreateRequest struct {
	Owner       string `json:"owner"`
	Name        string `json:"name"`
	Description string `json:"description"`
	Visibility  string `json:"visibility"`
}

// internalCreateResponse is returned on success. Created distinguishes a
// freshly provisioned database (201) from one that already existed (200), so
// the caller can decide whether to announce the companion to the user.
type internalCreateResponse struct {
	URL     string `json:"url"`
	Created bool   `json:"created"`
}

// internalAuthGuard authenticates a service-to-service request the same way
// core-go's auth.internalAuth does — the source IP must fall inside
// [sr.ht]internal-ipnet AND the Authorization header must be a valid,
// unexpired "Internal <fernet-token>" minted with the shared [sr.ht]network-key.
// The network-key check is the real guard (only internal services hold it); the
// IP check is defense-in-depth against the endpoint being reachable through the
// public Traefik route. We reimplement rather than reuse auth.Middleware because
// that middleware 401s any request without a cookie/bearer and pulls in the
// full user-resolution path we do not need here — the owner comes from the body.
func internalAuthGuard(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		host, _, err := net.SplitHostPort(r.RemoteAddr)
		if err != nil {
			host = r.RemoteAddr
		}
		ip := net.ParseIP(host)
		if ip == nil || !config.IsInternalIP(ip) {
			http.Error(w, "internal auth: source IP not permitted", http.StatusUnauthorized)
			return
		}

		parts := strings.SplitN(r.Header.Get("Authorization"), " ", 2)
		if len(parts) != 2 || !strings.EqualFold(parts[0], "internal") {
			http.Error(w, "internal auth: Internal authorization required", http.StatusUnauthorized)
			return
		}
		payload := crypto.DecryptWithExpiration([]byte(parts[1]), 30*time.Second)
		if payload == nil {
			http.Error(w, "internal auth: invalid or expired token", http.StatusForbidden)
			return
		}
		var ia struct {
			ClientID string `json:"client_id"`
			NodeID   string `json:"node_id"`
		}
		if err := json.Unmarshal(payload, &ia); err != nil || ia.ClientID == "" || ia.NodeID == "" {
			http.Error(w, "internal auth: malformed token", http.StatusForbidden)
			return
		}
		next.ServeHTTP(w, r)
	})
}

// handleInternalCreate provisions a Dolt database for owner/name. It mirrors
// handleCreate's insert-row-then-init-store ordering (so metadata and disk
// never diverge) but is idempotent: a repeated call for an existing companion
// returns 200 instead of an error, because the caller fires on every push and
// must not fail once the DB already exists.
func (a *app) handleInternalCreate(w http.ResponseWriter, r *http.Request) {
	var req internalCreateRequest
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil {
		http.Error(w, "malformed JSON body", http.StatusBadRequest)
		return
	}
	req.Owner = strings.TrimPrefix(strings.TrimSpace(req.Owner), "~")
	req.Name = strings.TrimSpace(req.Name)

	if req.Owner == "" {
		http.Error(w, "owner is required", http.StatusBadRequest)
		return
	}
	if err := core.ValidateName(req.Name); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	visibility := core.VisibilityPrivate
	if req.Visibility != "" {
		v, ok := parseVisibility(req.Visibility)
		if !ok {
			http.Error(w, "invalid visibility", http.StatusBadRequest)
			return
		}
		visibility = v
	}

	ctx := r.Context()

	// Resolve (and, on first sight, mirror) the owner's account so we have a
	// local UserID to own the row. A permanent miss (no such meta user) is a
	// client error, not a server fault.
	caller, err := a.cfg.Users.LookupUser(ctx, req.Owner)
	if err != nil {
		http.Error(w, fmt.Sprintf("resolve owner %q: %v", req.Owner, err), http.StatusUnprocessableEntity)
		return
	}

	url := a.repoURL(caller.Username, req.Name)
	diskPath := a.cfg.RepoDiskPath(caller.Username, req.Name)
	repo := &core.Repo{
		Name:        req.Name,
		Description: req.Description,
		OwnerID:     caller.UserID,
		OwnerName:   caller.Username,
		Path:        diskPath,
		Visibility:  visibility,
	}

	// Insert the metadata row first: a name collision (ErrNameTaken) means the
	// companion already exists, which for this idempotent endpoint is success,
	// not an error — return 200 without touching disk.
	created, err := a.cfg.Repos.CreateRepo(ctx, repo)
	if err != nil {
		if errors.Is(err, db.ErrNameTaken) {
			writeJSON(w, http.StatusOK, internalCreateResponse{URL: url, Created: false})
			return
		}
		http.Error(w, "create database", http.StatusInternalServerError)
		return
	}

	// The initial empty commit's author is cosmetic (real pushes overwrite
	// history); mirror handleCreate and author it as the instance owner,
	// falling back to the database owner.
	authorName, authorEmail := config.GetOwner(a.cfg.Conf)
	if authorName == "" {
		authorName = caller.Username
	}
	if authorEmail == "" {
		authorEmail = caller.Username + "@" + hostOf(config.GetOrigin(a.cfg.Conf, serviceName, true))
	}
	if err := a.cfg.Stores.InitStore(ctx, diskPath, authorName, authorEmail); err != nil {
		// InitStore self-cleans its directory; undo the metadata row too so a
		// failed provision leaves nothing behind and a retry can start clean.
		_ = a.cfg.Repos.DeleteRepo(ctx, created.ID)
		http.Error(w, "initialize database store", http.StatusInternalServerError)
		return
	}
	writeJSON(w, http.StatusCreated, internalCreateResponse{URL: url, Created: true})
}

// repoURL builds the external web URL for a database, e.g.
// https://dolt.srht.bigb.es/~owner/name.
func (a *app) repoURL(owner, name string) string {
	origin := strings.TrimRight(config.GetOrigin(a.cfg.Conf, serviceName, true), "/")
	return fmt.Sprintf("%s/~%s/%s", origin, owner, name)
}

// hostOf returns the host authority of a URL, or the input unchanged if it does
// not parse as one (used only to synthesize a cosmetic commit-author email).
func hostOf(origin string) string {
	if i := strings.Index(origin, "://"); i >= 0 {
		origin = origin[i+3:]
	}
	if i := strings.IndexByte(origin, '/'); i >= 0 {
		origin = origin[:i]
	}
	if origin == "" {
		return "localhost"
	}
	return origin
}

// writeJSON writes v as a JSON response with the given status.
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

A web/handlers_internal_test.go => web/handlers_internal_test.go +118 -0
@@ 0,0 1,118 @@
package web

import (
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
	"sourcecraft.dev/bigbes/sr-ht-dolt/db"
)

// postInternalCreate calls handleInternalCreate directly with a JSON body,
// bypassing the router (and thus internalAuthGuard, which is exercised
// separately). The auth guard needs the process-global crypto/config state a
// unit test does not set up; the business logic under test here does not.
func (h *harness) postInternalCreate(body string) *httptest.ResponseRecorder {
	req := httptest.NewRequest("POST", "/internal/repos", strings.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	rec := httptest.NewRecorder()
	h.app.handleInternalCreate(rec, req)
	return rec
}

func TestInternalCreateProvisions(t *testing.T) {
	h := newHarness(t)
	h.users.byName["alice"] = &core.Caller{UserID: 7, Username: "alice"}

	rec := h.postInternalCreate(`{"owner":"~alice","name":"widgets","description":"hi"}`)
	if rec.Code != http.StatusCreated {
		t.Fatalf("create: got %d, want 201 (body: %s)", rec.Code, rec.Body.String())
	}
	if !strings.Contains(rec.Body.String(), `"created":true`) {
		t.Fatalf("expected created:true, got %s", rec.Body.String())
	}
	if !strings.Contains(rec.Body.String(), "/~alice/widgets") {
		t.Fatalf("expected companion URL in body, got %s", rec.Body.String())
	}

	// Row inserted with the resolved owner id and default (PRIVATE) visibility.
	if len(h.store.createdCalls) != 1 {
		t.Fatalf("expected 1 CreateRepo call, got %d", len(h.store.createdCalls))
	}
	got := h.store.createdCalls[0]
	if got.OwnerID != 7 || got.OwnerName != "alice" || got.Name != "widgets" {
		t.Fatalf("unexpected repo: %+v", got)
	}
	if got.Visibility != core.VisibilityPrivate {
		t.Fatalf("default visibility: got %q, want PRIVATE", got.Visibility)
	}
	// On-disk store initialized at the mapped path.
	if len(h.stores.initCalls) != 1 || !strings.HasSuffix(h.stores.initCalls[0], "/~alice/widgets") {
		t.Fatalf("expected InitStore at ~alice/widgets, got %v", h.stores.initCalls)
	}
}

func TestInternalCreateIdempotent(t *testing.T) {
	h := newHarness(t)
	h.users.byName["alice"] = &core.Caller{UserID: 7, Username: "alice"}
	h.store.add(&core.Repo{Name: "widgets", OwnerID: 7, OwnerName: "alice", Path: "/p", Visibility: core.VisibilityPrivate})

	rec := h.postInternalCreate(`{"owner":"alice","name":"widgets"}`)
	if rec.Code != http.StatusOK {
		t.Fatalf("existing companion: got %d, want 200 (body: %s)", rec.Code, rec.Body.String())
	}
	if !strings.Contains(rec.Body.String(), `"created":false`) {
		t.Fatalf("expected created:false, got %s", rec.Body.String())
	}
	// Must NOT touch disk when the row already exists.
	if len(h.stores.initCalls) != 0 {
		t.Fatalf("InitStore must not run for an existing companion, got %v", h.stores.initCalls)
	}
}

func TestInternalCreateRollsBackOnStoreFailure(t *testing.T) {
	h := newHarness(t)
	h.users.byName["alice"] = &core.Caller{UserID: 7, Username: "alice"}
	h.stores.initErr = errInitBoom

	rec := h.postInternalCreate(`{"owner":"alice","name":"widgets"}`)
	if rec.Code != http.StatusInternalServerError {
		t.Fatalf("store failure: got %d, want 500", rec.Code)
	}
	// The metadata row inserted before InitStore must be rolled back.
	if len(h.store.deletedRepos) != 1 {
		t.Fatalf("expected rollback DeleteRepo, got deletes=%v", h.store.deletedRepos)
	}
	if _, err := h.store.GetRepoByOwnerAndName(nil, "alice", "widgets"); err != db.ErrNotFound {
		t.Fatalf("expected repo removed after rollback, err=%v", err)
	}
}

func TestInternalCreateUnknownOwner(t *testing.T) {
	h := newHarness(t)
	// alice is not registered in fakeUsers -> LookupUser fails.
	rec := h.postInternalCreate(`{"owner":"alice","name":"widgets"}`)
	if rec.Code != http.StatusUnprocessableEntity {
		t.Fatalf("unknown owner: got %d, want 422 (body: %s)", rec.Code, rec.Body.String())
	}
	if len(h.store.createdCalls) != 0 {
		t.Fatalf("must not create a row for an unresolvable owner")
	}
}

func TestInternalCreateRejectsBadName(t *testing.T) {
	h := newHarness(t)
	h.users.byName["alice"] = &core.Caller{UserID: 7, Username: "alice"}
	rec := h.postInternalCreate(`{"owner":"alice","name":".."}`)
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("bad name: got %d, want 400", rec.Code)
	}
}

var errInitBoom = &boomError{}

type boomError struct{}

func (*boomError) Error() string { return "boom" }

M web/router.go => web/router.go +5 -0
@@ 68,6 68,11 @@ func (a *app) mount(r chi.Router) {
	r.Get("/create", a.handleCreateForm)
	r.Post("/create", a.handleCreate)

	// Service-to-service companion provisioning (git.sr.ht post-update hook).
	// Guarded by internal-network + network-key auth, not the cookie/CSRF the
	// browser routes use.
	r.With(internalAuthGuard).Post("/internal/repos", a.handleInternalCreate)

	r.Get("/settings/keys", a.handleKeys)
	r.Post("/settings/keys", a.handleKeysPost)