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)