gitmote

gitmote  ·  history  ·  branches

feat: flat single-segment repo namespace with visibility and default-branch force-push guard

39179888473d244837009f39a6ddbdffdde84add
Atanas Minev <atmin@protonmail.com> · 2026-07-05 20:54

diff --git a/README.md b/README.md
index 5f57951..8820f74 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@ token to the logs behind a `SAVE IT NOW` banner — grab it there (no setup page
Then sign in at <http://localhost:8080/login> by pasting it, and clone/push:
```sh
-git clone http://admin:<token>@localhost:8080/<owner>/<repo> # create the repo in /ui first
+git clone http://admin:<token>@localhost:8080/<repo> # create the repo in /ui first
```
Lost the token? It's never stored (only its hash), so re-mint one — stop the
@@ -59,7 +59,7 @@ env surface, CI, and the Scaleway deployment.
`make dev` gives you a running instance in one command: it builds the binaries,
starts MinIO in a container (S3 on :9100), runs gitmote **natively** on :8080,
-and ensures an admin + a `atmin/gitmote` repo (auto-bootstrap), minting a fresh
+and ensures an admin + a `gitmote` repo (auto-bootstrap), minting a fresh
token each run and printing it. State (the metadata DB and object cache) persists
under `data/`, so repos and history survive restarts:
diff --git a/cmd/gitmote-hook/main.go b/cmd/gitmote-hook/main.go
index 851e945..05d6eb0 100644
--- a/cmd/gitmote-hook/main.go
+++ b/cmd/gitmote-hook/main.go
@@ -9,12 +9,17 @@ import (
"fmt"
"io"
"os"
+ "os/exec"
"path/filepath"
"strings"
"github.com/atmin/gitmote/internal/hookrpc"
)
+// zeroSHA is git's all-zero object id: the "absent" side of a create (old) or a
+// delete (new).
+const zeroSHA = "0000000000000000000000000000000000000000"
+
func main() {
if err := run(os.Stdin); err != nil {
fmt.Fprintln(os.Stderr, "gitmote:", err)
@@ -45,6 +50,9 @@ func run(stdin io.Reader) error {
if err != nil {
return err
}
+ for i := range commands {
+ commands[i].Force = isForce(commands[i].Old, commands[i].New)
+ }
resp, err := hookrpc.Call(sock, hookrpc.Request{
Nonce: nonce,
@@ -60,6 +68,24 @@ func run(stdin io.Reader) error {
return nil
}
+// isForce reports whether a ref update rewrites history: a deletion (new is the
+// zero id) or a non-fast-forward (old is not an ancestor of new). A create (old
+// is the zero id) is not a force. It runs `git merge-base --is-ancestor` in the
+// bare repo, where receive-pack has both the existing objects and the pushed
+// ones (the quarantine) visible; a non-zero exit — not-ancestor, or any git
+// error — is treated as a force so the default-branch guard fails safe.
+func isForce(old, new string) bool {
+ switch {
+ case new == zeroSHA:
+ return true // delete
+ case old == zeroSHA:
+ return false // create
+ default:
+ err := exec.Command("git", "merge-base", "--is-ancestor", old, new).Run()
+ return err != nil // exit 0 = ancestor (fast-forward); non-zero = non-fast-forward
+ }
+}
+
// parseCommands reads the `<old> <new> <ref>` lines git feeds a pre-receive hook.
func parseCommands(r io.Reader) ([]hookrpc.RefCommand, error) {
var commands []hookrpc.RefCommand
diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index 75003e4..a3fcb29 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -77,7 +77,7 @@ func main() {
func runBootstrap(ctx context.Context, args []string, out io.Writer) error {
fs := flag.NewFlagSet("bootstrap", flag.ContinueOnError)
handle := fs.String("handle", os.Getenv("GITMOTE_ADMIN_HANDLE"), "admin user handle (default admin, or GITMOTE_ADMIN_HANDLE)")
- repoName := fs.String("repo", "", "optional initial repository, e.g. atmin/gitmote (repos are otherwise made in the UI)")
+ repoName := fs.String("repo", "", "optional initial repository, e.g. gitmote (repos are otherwise made in the UI)")
branch := fs.String("default-branch", "main", "default branch for the initial repo")
reissue := fs.Bool("reissue", false, "mint a fresh token for the existing admin (recover a lost token)")
if err := fs.Parse(args); err != nil {
@@ -122,7 +122,7 @@ func writeBootstrapBanner(out io.Writer, res *bootstrap.Result) error {
const bar = "========================================================================"
repoLines := fmt.Sprintf(
" Repos: create one in the UI, then clone:\n"+
- " git clone http://%s:<token>@<host>/<owner>/<repo>\n",
+ " git clone http://%s:<token>@<host>/<repo>\n",
res.Admin.Handle)
if res.Repo != nil {
repoLines = fmt.Sprintf(
diff --git a/cmd/gitmote/main_test.go b/cmd/gitmote/main_test.go
index 3cad599..32f0e04 100644
--- a/cmd/gitmote/main_test.go
+++ b/cmd/gitmote/main_test.go
@@ -161,11 +161,11 @@ func TestRunBootstrap(t *testing.T) {
t.Setenv("GITMOTE_DB", dbPath)
var out bytes.Buffer
- args := []string{"-handle", "atmin", "-repo", "atmin/gitmote"}
+ args := []string{"-handle", "atmin", "-repo", "gitmote"}
if err := runBootstrap(context.Background(), args, &out); err != nil {
t.Fatalf("runBootstrap: %v", err)
}
- if !strings.Contains(out.String(), "atmin/gitmote") || !strings.Contains(out.String(), "access token") {
+ if !strings.Contains(out.String(), "gitmote") || !strings.Contains(out.String(), "access token") {
t.Errorf("bootstrap output missing repo/token:\n%s", out.String())
}
@@ -490,7 +490,7 @@ func claimStatus(t *testing.T, base, secret string) int {
func seedWorkflowRepo(t *testing.T, md *meta.Metadata, s store.Store) (*meta.Repo, string) {
t.Helper()
ctx := context.Background()
- const name = "atmin/app"
+ const name = "app"
src := t.TempDir()
git := func(args ...string) {
diff --git a/docs/architecture/auth.md b/docs/architecture/auth.md
index fcb2457..50f53c2 100644
--- a/docs/architecture/auth.md
+++ b/docs/architecture/auth.md
@@ -5,7 +5,19 @@
- **Smart HTTP first.** `git http-backend` over HTTPS, authenticated with
bearer **personal access tokens** (hashed in `tokens`). Stateless, firewall-
friendly, and the natural fit for a scale-to-zero container.
-- **Authorization is per-repo**, read from `acls` on every request.
+- **Authorization is per-repo**, read from `acls` on every request. The levels
+ are `read` (spectator), `write` (collaborator), and `admin` (repo management).
+- **Visibility gates reads, not writes.** `repos.visibility` is `private`
+ (default) or `public`. A **public** repo is readable — clone, fetch, and
+ browse — with **no token**; reading a private repo needs an ACL. Writes and
+ management are **never** anonymous: `git-receive-pack` and every management
+ action always require the matching `write`/`admin` ACL. See
+ [urls.md](urls.md) for the flat namespace this sits in.
+- **Force-pushing the default branch is admin-only.** A `write` collaborator may
+ fast-forward any branch and force-push or delete *non-default* branches, but a
+ non-fast-forward or deletion of `repos.default_branch` requires `admin`. It is
+ enforced on the write path (the pre-receive hook, where old→new ancestry is
+ known), not at request authorization — see [safety.md](safety.md).
- **The management UI reuses the same tokens.** A browser signs in by pasting a
PAT once (verified through the identical path); the server then issues an
HMAC-signed, stateless session cookie (no sessions table — no new source of
diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md
index 602ec75..61589ba 100644
--- a/docs/architecture/storage.md
+++ b/docs/architecture/storage.md
@@ -60,8 +60,10 @@ dependency); `rclone` is a zero-code fallback. New objects/packs are PUT after
```sql
CREATE TABLE repos (
id INTEGER PRIMARY KEY,
- name TEXT NOT NULL UNIQUE, -- "atmin/dotfiles"
+ name TEXT NOT NULL UNIQUE, -- single path segment, e.g. "gitmote"
default_branch TEXT NOT NULL DEFAULT 'main',
+ visibility TEXT NOT NULL DEFAULT 'private' -- 'private' | 'public' (public = read-anonymous)
+ CHECK (visibility IN ('private','public')),
created_at TEXT NOT NULL
);
diff --git a/docs/architecture/urls.md b/docs/architecture/urls.md
index c94fcca..6155de4 100644
--- a/docs/architecture/urls.md
+++ b/docs/architecture/urls.md
@@ -5,10 +5,12 @@ people. That single fact sets the whole URL shape — repos live in a **flat,
single-segment namespace** (`gitmote.example.com/<repo>`), not `user/repo`. There
is no per-user namespace to disambiguate, so there is nothing to disambiguate.
-> **Status:** target design. It is implemented by the `tasks/urls-*` chain; the
-> sections here are reconciled into [storage.md](storage.md), [auth.md](auth.md),
-> and [request-flows.md](request-flows.md) as those tasks land. Until then the
-> code still serves the old `owner/repo` scheme.
+> **Status:** partially landed. The **flat single-segment namespace** and
+> **access & visibility** (public → anonymous read, repo-read browse, admin-only
+> default-branch force-push) are implemented and reconciled into
+> [storage.md](storage.md) and [auth.md](auth.md). Still to land from the
+> `tasks/urls-*` chain: the `tree`/`blob`/`raw` content routing, rendered-markdown
+> link rewriting, and the dashboard UI on the new routes.
## The namespace
diff --git a/docs/ops.md b/docs/ops.md
index 321b80d..4803164 100644
--- a/docs/ops.md
+++ b/docs/ops.md
@@ -238,7 +238,7 @@ scw container container create \
scw container domain create container-id=<CONTAINER_ID> hostname=gitmote.atmin.net
scw container domain list container-id=<CONTAINER_ID> # status pending → ready (minutes)
# Note: gitmote has no route at "/", so http(s)://gitmote.atmin.net/ returns 404
-# by design; check /healthz for liveness. Git lives under /<owner>/<repo>/….
+# by design; check /healthz for liveness. Git lives under /<repo>/….
# 5. CI runner Serverless Job — point its definition at the public GHCR runner
# image (one image serves all repos; per-job env is injected at trigger). Set
@@ -316,8 +316,8 @@ git push origin master
```bash
# Push the gitmote repo over HTTPS to the deployed instance:
-git push https://atmin:<token>@gitmote.atmin.net/atmin/gitmote HEAD:refs/heads/master
-git clone https://atmin:<token>@gitmote.atmin.net/atmin/gitmote /tmp/gitmote-clone
+git push https://atmin:<token>@gitmote.atmin.net/gitmote HEAD:refs/heads/master
+git clone https://atmin:<token>@gitmote.atmin.net/gitmote /tmp/gitmote-clone
git -C /tmp/gitmote-clone fsck --full # clean
```
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
index b729754..1c3a6e8 100644
--- a/internal/auth/auth_test.go
+++ b/internal/auth/auth_test.go
@@ -86,7 +86,7 @@ func TestAuthorize(t *testing.T) {
m := openMeta(t)
g := NewGuard(m)
- repo, err := m.CreateRepo(ctx, "atmin/repo", "main")
+ repo, err := m.CreateRepo(ctx, "repo", "main")
if err != nil {
t.Fatalf("CreateRepo: %v", err)
}
@@ -101,18 +101,18 @@ func TestAuthorize(t *testing.T) {
"wrong secret": bearer("gmt_" + sel + ".wrongsecret"),
}
for name, r := range unauth {
- if _, err := g.Authorize(r, "atmin/repo", meta.PermRead); !errors.Is(err, ErrUnauthorized) {
+ if _, err := g.Authorize(r, "repo", meta.PermRead); !errors.Is(err, ErrUnauthorized) {
t.Errorf("%s: Authorize = %v, want ErrUnauthorized", name, err)
}
}
// Valid token, no ACL → ErrForbidden.
- if _, err := g.Authorize(bearer(raw), "atmin/repo", meta.PermRead); !errors.Is(err, ErrForbidden) {
+ if _, err := g.Authorize(bearer(raw), "repo", meta.PermRead); !errors.Is(err, ErrForbidden) {
t.Errorf("no ACL: Authorize = %v, want ErrForbidden", err)
}
// Valid token, unknown repo → meta.ErrNotFound.
- if _, err := g.Authorize(bearer(raw), "no/such", meta.PermRead); !errors.Is(err, meta.ErrNotFound) {
+ if _, err := g.Authorize(bearer(raw), "nope", meta.PermRead); !errors.Is(err, meta.ErrNotFound) {
t.Errorf("unknown repo: Authorize = %v, want meta.ErrNotFound", err)
}
@@ -120,14 +120,14 @@ func TestAuthorize(t *testing.T) {
if err := m.SetACL(ctx, repo.ID, userID, meta.PermRead); err != nil {
t.Fatalf("SetACL: %v", err)
}
- user, err := g.Authorize(bearer(raw), "atmin/repo", meta.PermRead)
+ user, err := g.Authorize(bearer(raw), "repo", meta.PermRead)
if err != nil {
t.Fatalf("read with read ACL: %v", err)
}
if user.ID != userID {
t.Errorf("authorized user = %d, want %d", user.ID, userID)
}
- if _, err := g.Authorize(bearer(raw), "atmin/repo", meta.PermWrite); !errors.Is(err, ErrForbidden) {
+ if _, err := g.Authorize(bearer(raw), "repo", meta.PermWrite); !errors.Is(err, ErrForbidden) {
t.Errorf("write with read ACL: Authorize = %v, want ErrForbidden", err)
}
@@ -135,7 +135,7 @@ func TestAuthorize(t *testing.T) {
if err := m.SetACL(ctx, repo.ID, userID, meta.PermAdmin); err != nil {
t.Fatalf("SetACL admin: %v", err)
}
- if _, err := g.Authorize(bearer(raw), "atmin/repo", meta.PermRead); err != nil {
+ if _, err := g.Authorize(bearer(raw), "repo", meta.PermRead); err != nil {
t.Errorf("read with admin ACL: %v", err)
}
@@ -178,7 +178,7 @@ func TestBasicAuthCredentials(t *testing.T) {
ctx := context.Background()
m := openMeta(t)
g := NewGuard(m)
- repo, _ := m.CreateRepo(ctx, "atmin/repo", "main")
+ repo, _ := m.CreateRepo(ctx, "repo", "main")
raw, userID := seedToken(t, m)
if err := m.SetACL(ctx, repo.ID, userID, meta.PermRead); err != nil {
t.Fatalf("SetACL: %v", err)
@@ -194,7 +194,7 @@ func TestBasicAuthCredentials(t *testing.T) {
} {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.SetBasicAuth(creds.user, creds.pass)
- if _, err := g.Authorize(r, "atmin/repo", meta.PermRead); err != nil {
+ if _, err := g.Authorize(r, "repo", meta.PermRead); err != nil {
t.Errorf("Basic(%q,%q): Authorize = %v, want ok", creds.user, creds.pass, err)
}
}
@@ -204,7 +204,7 @@ func TestTokenExpiry(t *testing.T) {
ctx := context.Background()
m := openMeta(t)
g := NewGuard(m)
- repo, _ := m.CreateRepo(ctx, "atmin/repo", "main")
+ repo, _ := m.CreateRepo(ctx, "repo", "main")
u, _ := m.CreateUser(ctx, "atmin")
if err := m.SetACL(ctx, repo.ID, u.ID, meta.PermRead); err != nil {
t.Fatalf("SetACL: %v", err)
@@ -218,13 +218,13 @@ func TestTokenExpiry(t *testing.T) {
// Before expiry: authorizes.
g.now = func() time.Time { return expiry.Add(-time.Minute) }
- if _, err := g.Authorize(bearer(raw), "atmin/repo", meta.PermRead); err != nil {
+ if _, err := g.Authorize(bearer(raw), "repo", meta.PermRead); err != nil {
t.Errorf("before expiry: Authorize = %v, want ok", err)
}
// At/after expiry: rejected as unauthorized.
g.now = func() time.Time { return expiry }
- if _, err := g.Authorize(bearer(raw), "atmin/repo", meta.PermRead); !errors.Is(err, ErrUnauthorized) {
+ if _, err := g.Authorize(bearer(raw), "repo", meta.PermRead); !errors.Is(err, ErrUnauthorized) {
t.Errorf("at expiry: Authorize = %v, want ErrUnauthorized", err)
}
g.now = func() time.Time { return expiry.Add(time.Hour) }
@@ -237,8 +237,8 @@ func TestRepoScope(t *testing.T) {
ctx := context.Background()
m := openMeta(t)
g := NewGuard(m)
- repoA, _ := m.CreateRepo(ctx, "atmin/a", "main")
- repoB, _ := m.CreateRepo(ctx, "atmin/b", "main")
+ repoA, _ := m.CreateRepo(ctx, "a", "main")
+ repoB, _ := m.CreateRepo(ctx, "b", "main")
u, _ := m.CreateUser(ctx, "atmin")
// The owner has read on BOTH repos.
if err := m.SetACL(ctx, repoA.ID, u.ID, meta.PermRead); err != nil {
@@ -253,10 +253,10 @@ func TestRepoScope(t *testing.T) {
if err != nil {
t.Fatalf("MintScoped: %v", err)
}
- if _, err := g.Authorize(bearer(raw), "atmin/a", meta.PermRead); err != nil {
+ if _, err := g.Authorize(bearer(raw), "a", meta.PermRead); err != nil {
t.Errorf("scoped repo: Authorize = %v, want ok", err)
}
- if _, err := g.Authorize(bearer(raw), "atmin/b", meta.PermRead); !errors.Is(err, ErrForbidden) {
+ if _, err := g.Authorize(bearer(raw), "b", meta.PermRead); !errors.Is(err, ErrForbidden) {
t.Errorf("other repo: Authorize = %v, want ErrForbidden", err)
}
}
@@ -265,7 +265,7 @@ func TestReadOnlyToken(t *testing.T) {
ctx := context.Background()
m := openMeta(t)
g := NewGuard(m)
- repo, _ := m.CreateRepo(ctx, "atmin/repo", "main")
+ repo, _ := m.CreateRepo(ctx, "repo", "main")
u, _ := m.CreateUser(ctx, "atmin")
// The owner can write, but the token is read-only.
if err := m.SetACL(ctx, repo.ID, u.ID, meta.PermWrite); err != nil {
@@ -276,10 +276,10 @@ func TestReadOnlyToken(t *testing.T) {
if err != nil {
t.Fatalf("MintScoped: %v", err)
}
- if _, err := g.Authorize(bearer(raw), "atmin/repo", meta.PermRead); err != nil {
+ if _, err := g.Authorize(bearer(raw), "repo", meta.PermRead); err != nil {
t.Errorf("read with read-only token: Authorize = %v, want ok", err)
}
- if _, err := g.Authorize(bearer(raw), "atmin/repo", meta.PermWrite); !errors.Is(err, ErrForbidden) {
+ if _, err := g.Authorize(bearer(raw), "repo", meta.PermWrite); !errors.Is(err, ErrForbidden) {
t.Errorf("write with read-only token: Authorize = %v, want ErrForbidden", err)
}
}
@@ -288,7 +288,7 @@ func TestUnscopedTokenUnaffected(t *testing.T) {
ctx := context.Background()
m := openMeta(t)
g := NewGuard(m)
- repo, _ := m.CreateRepo(ctx, "atmin/repo", "main")
+ repo, _ := m.CreateRepo(ctx, "repo", "main")
raw, userID := seedToken(t, m) // ordinary CreateToken PAT
if err := m.SetACL(ctx, repo.ID, userID, meta.PermWrite); err != nil {
t.Fatalf("SetACL: %v", err)
@@ -296,11 +296,51 @@ func TestUnscopedTokenUnaffected(t *testing.T) {
// No expiry, any owned repo, read + write per ACL — even far in the future.
g.now = func() time.Time { return time.Date(3000, 1, 1, 0, 0, 0, 0, time.UTC) }
- if _, err := g.Authorize(bearer(raw), "atmin/repo", meta.PermWrite); err != nil {
+ if _, err := g.Authorize(bearer(raw), "repo", meta.PermWrite); err != nil {
t.Errorf("unscoped token write: Authorize = %v, want ok", err)
}
}
+func TestAuthorizeVisibility(t *testing.T) {
+ ctx := context.Background()
+ m := openMeta(t)
+ g := NewGuard(m)
+
+ pub, _ := m.CreateRepo(ctx, "pub", "main")
+ if err := m.SetVisibility(ctx, pub.ID, meta.VisibilityPublic); err != nil {
+ t.Fatalf("SetVisibility: %v", err)
+ }
+ if _, err := m.CreateRepo(ctx, "priv", "main"); err != nil {
+ t.Fatalf("CreateRepo: %v", err)
+ }
+
+ // Anonymous read of a public repo is allowed — a nil user, no error.
+ if u, err := g.Authorize(bearer(""), "pub", meta.PermRead); err != nil || u != nil {
+ t.Errorf("anon read public = (%v, %v), want (nil, nil)", u, err)
+ }
+ // Writes are never anonymous, public or not.
+ if _, err := g.Authorize(bearer(""), "pub", meta.PermWrite); !errors.Is(err, ErrUnauthorized) {
+ t.Errorf("anon write public = %v, want ErrUnauthorized", err)
+ }
+ // Anonymous read of a private repo is refused (and existence is hidden).
+ if _, err := g.Authorize(bearer(""), "priv", meta.PermRead); !errors.Is(err, ErrUnauthorized) {
+ t.Errorf("anon read private = %v, want ErrUnauthorized", err)
+ }
+ // Anonymous read of a missing repo is also 401, not 404 — no inventory leak.
+ if _, err := g.Authorize(bearer(""), "missing", meta.PermRead); !errors.Is(err, ErrUnauthorized) {
+ t.Errorf("anon read missing = %v, want ErrUnauthorized", err)
+ }
+
+ // A valid token with no ACL still reads a public repo, but not a private one.
+ raw, _ := seedToken(t, m)
+ if _, err := g.Authorize(bearer(raw), "pub", meta.PermRead); err != nil {
+ t.Errorf("token read public (no ACL) = %v, want ok", err)
+ }
+ if _, err := g.Authorize(bearer(raw), "priv", meta.PermRead); !errors.Is(err, ErrForbidden) {
+ t.Errorf("token read private (no ACL) = %v, want ErrForbidden", err)
+ }
+}
+
func TestAllows(t *testing.T) {
cases := []struct {
granted, required meta.Perm
diff --git a/internal/auth/guard.go b/internal/auth/guard.go
index 5733e9e..b065c66 100644
--- a/internal/auth/guard.go
+++ b/internal/auth/guard.go
@@ -21,30 +21,47 @@ type Guard struct {
// NewGuard returns a Guard backed by the metadata layer.
func NewGuard(md *meta.Metadata) *Guard { return &Guard{md: md, now: time.Now} }
-// Authorize verifies the request's token and checks the resulting user holds at
-// least perm on repoName. The returned error selects the response:
+// Authorize checks the request may act on repoName at perm. A public repo is
+// readable with no token; every other case needs a valid token plus the ACL
+// level. The returned error selects the response:
//
-// - ErrUnauthorized — no/invalid token: 401 with a Basic challenge;
+// - ErrUnauthorized — no/invalid token where one is required: 401 with a Basic challenge;
// - ErrForbidden — authenticated but lacking the permission: 403;
// - meta.ErrNotFound — the repo does not exist: 404;
// - any other error — an internal failure: 500.
//
-// It returns the authenticated user on success.
+// On success it returns the authenticated user, or nil for an anonymous read of
+// a public repo (reads never dereference the user; only the write path does).
func (g *Guard) Authorize(r *http.Request, repoName string, perm meta.Perm) (*meta.User, error) {
ctx := r.Context()
- raw, ok := tokenFromRequest(r)
- if !ok {
- return nil, ErrUnauthorized
+ raw, hasToken := tokenFromRequest(r)
+
+ repo, err := g.md.GetRepo(ctx, repoName)
+ if errors.Is(err, meta.ErrNotFound) {
+ // Hide a private forge's repo inventory from anonymous callers: an
+ // unauthenticated request never learns whether a repo exists (it gets the
+ // same 401 challenge whether or not it does).
+ if !hasToken {
+ return nil, ErrUnauthorized
+ }
+ return nil, err // 404 to an authenticated caller
}
- vt, err := g.verify(ctx, raw)
if err != nil {
return nil, err
}
- repo, err := g.md.GetRepo(ctx, repoName)
+ // Anonymous: allowed only for reading a public repo; anything else needs a token.
+ if !hasToken {
+ if perm == meta.PermRead && repo.Public() {
+ return nil, nil
+ }
+ return nil, ErrUnauthorized
+ }
+
+ vt, err := g.verify(ctx, raw)
if err != nil {
- return nil, err // meta.ErrNotFound flows through to a 404
+ return nil, err
}
// Token constraints gate before the ACL: a repo-scoped token reaches only its
@@ -57,6 +74,19 @@ func (g *Guard) Authorize(r *http.Request, repoName string, perm meta.Perm) (*me
return nil, ErrForbidden
}
+ // Reading is gated by repo-read (public → anyone, private → any ACL), not by a
+ // specific ACL level; write/admin require the matching ACL level.
+ if perm == meta.PermRead {
+ ok, err := g.md.CanRead(ctx, repo, vt.user.ID)
+ if err != nil {
+ return nil, err
+ }
+ if !ok {
+ return nil, ErrForbidden
+ }
+ return &vt.user, nil
+ }
+
granted, err := g.md.GetACL(ctx, repo.ID, vt.user.ID)
if errors.Is(err, meta.ErrNotFound) {
return nil, ErrForbidden // no ACL row means no access
diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go
index 8b74cf7..7ad413d 100644
--- a/internal/bootstrap/bootstrap.go
+++ b/internal/bootstrap/bootstrap.go
@@ -22,8 +22,8 @@ const DefaultAdminHandle = "admin"
type Options struct {
// AdminHandle is the first global admin's handle; empty means DefaultAdminHandle.
AdminHandle string
- // RepoName is an optional initial repo, e.g. "atmin/gitmote"; empty creates
- // none (repos are made in the UI).
+ // RepoName is an optional initial repo, e.g. "gitmote"; empty creates none
+ // (repos are made in the UI).
RepoName string
// DefaultBranch for the initial repo; empty means "main". Ignored without a repo.
DefaultBranch string
diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go
index 7613f2f..f67364f 100644
--- a/internal/bootstrap/bootstrap_test.go
+++ b/internal/bootstrap/bootstrap_test.go
@@ -33,7 +33,7 @@ func TestRunCreatesUsableInstanceWithRepo(t *testing.T) {
ctx := context.Background()
m := openMeta(t)
- res, err := Run(ctx, m, Options{AdminHandle: "atmin", RepoName: "atmin/gitmote"})
+ res, err := Run(ctx, m, Options{AdminHandle: "atmin", RepoName: "gitmote"})
if err != nil {
t.Fatalf("Run: %v", err)
}
@@ -43,8 +43,8 @@ func TestRunCreatesUsableInstanceWithRepo(t *testing.T) {
if res.Admin == nil || !res.Admin.IsAdmin {
t.Fatalf("admin = %+v, want a global admin", res.Admin)
}
- if res.Repo == nil || res.Repo.Name != "atmin/gitmote" {
- t.Fatalf("repo = %+v, want atmin/gitmote", res.Repo)
+ if res.Repo == nil || res.Repo.Name != "gitmote" {
+ t.Fatalf("repo = %+v, want gitmote", res.Repo)
}
if res.RawToken == "" {
t.Fatal("no token returned")
@@ -54,7 +54,7 @@ func TestRunCreatesUsableInstanceWithRepo(t *testing.T) {
// initial repo (the admin holds a repo-admin ACL).
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+res.RawToken)
- user, err := auth.NewGuard(m).Authorize(req, "atmin/gitmote", meta.PermWrite)
+ user, err := auth.NewGuard(m).Authorize(req, "gitmote", meta.PermWrite)
if err != nil {
t.Fatalf("Authorize with bootstrap token: %v", err)
}
diff --git a/internal/ci/dispatcher_test.go b/internal/ci/dispatcher_test.go
index 3aef247..e937352 100644
--- a/internal/ci/dispatcher_test.go
+++ b/internal/ci/dispatcher_test.go
@@ -133,7 +133,7 @@ func newDispatcher(md *meta.Metadata, mz *repo.Materializer, tr Trigger) *Dispat
func TestDispatchOneWorkflowCreatesRunAndJob(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
- r, head := seedRepo(t, md, s, "atmin/app", map[string]string{
+ r, head := seedRepo(t, md, s, "app", map[string]string{
".github/workflows/ci.yml": "name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n",
})
@@ -162,7 +162,7 @@ func TestDispatchOneWorkflowCreatesRunAndJob(t *testing.T) {
t.Errorf("env run/job ids = %q/%q, want %d/%d",
env["GITMOTE_CI_RUN_ID"], env["GITMOTE_CI_JOB_ID"], runs[0].ID, jobs[0].ID)
}
- if env["GITMOTE_REPO"] != "atmin/app" || env["GITMOTE_SHA"] != head ||
+ if env["GITMOTE_REPO"] != "app" || env["GITMOTE_SHA"] != head ||
env["GITMOTE_REF"] != "refs/heads/main" {
t.Errorf("env checkout coords = %+v, want repo/sha/ref set", env)
}
@@ -174,7 +174,7 @@ func TestDispatchOneWorkflowCreatesRunAndJob(t *testing.T) {
func TestDispatchTwoWorkflowsCreateTwoJobs(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
- r, head := seedRepo(t, md, s, "atmin/app", map[string]string{
+ r, head := seedRepo(t, md, s, "app", map[string]string{
".github/workflows/ci.yml": "name: CI\non: push\n",
".github/workflows/deploy.yaml": "name: Deploy\non: push\n",
})
@@ -198,7 +198,7 @@ func TestDispatchTwoWorkflowsCreateTwoJobs(t *testing.T) {
func TestDispatchNoWorkflowsNoRun(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
- r, head := seedRepo(t, md, s, "atmin/app", nil) // no .github/workflows
+ r, head := seedRepo(t, md, s, "app", nil) // no .github/workflows
newDispatcher(md, mz, &stubTrigger{}).Dispatch(ctx, branchEvent(r, head))
@@ -210,7 +210,7 @@ func TestDispatchNoWorkflowsNoRun(t *testing.T) {
func TestDispatchMalformedWorkflowFailsRun(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
- r, head := seedRepo(t, md, s, "atmin/app", map[string]string{
+ r, head := seedRepo(t, md, s, "app", map[string]string{
".github/workflows/good.yml": "name: Good\non: push\n",
".github/workflows/bad.yml": "name: Bad\n : : not: valid: yaml\n\t- broken\n",
})
@@ -235,7 +235,7 @@ func TestDispatchMalformedWorkflowFailsRun(t *testing.T) {
func TestDispatchIgnoresTagsAndDeletes(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
- r, head := seedRepo(t, md, s, "atmin/app", map[string]string{
+ r, head := seedRepo(t, md, s, "app", map[string]string{
".github/workflows/ci.yml": "name: CI\non: push\n",
})
d := newDispatcher(md, mz, &stubTrigger{})
@@ -257,7 +257,7 @@ func TestDispatchIgnoresTagsAndDeletes(t *testing.T) {
func TestDispatchAllTriggersFailMarksRunError(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
- r, head := seedRepo(t, md, s, "atmin/app", map[string]string{
+ r, head := seedRepo(t, md, s, "app", map[string]string{
".github/workflows/ci.yml": "name: CI\non: push\n",
})
@@ -277,7 +277,7 @@ func TestDispatchAllTriggersFailMarksRunError(t *testing.T) {
func TestDispatchOneTriggerFailsOthersProceed(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
- r, head := seedRepo(t, md, s, "atmin/app", map[string]string{
+ r, head := seedRepo(t, md, s, "app", map[string]string{
".github/workflows/a.yml": "name: A\non: push\n",
".github/workflows/b.yml": "name: B\non: push\n",
})
@@ -333,7 +333,7 @@ func (m *stubMinter) MintScoped(_ context.Context, userID int64, _ string, repoS
func TestDispatchMintsScopedCloneToken(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
- r, head := seedRepo(t, md, s, "atmin/app", map[string]string{
+ r, head := seedRepo(t, md, s, "app", map[string]string{
".github/workflows/ci.yml": "name: CI\non: push\n",
})
@@ -371,7 +371,7 @@ func TestDispatchMintsScopedCloneToken(t *testing.T) {
func TestDispatchMintFailureLeavesRunUsable(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
- r, head := seedRepo(t, md, s, "atmin/app", map[string]string{
+ r, head := seedRepo(t, md, s, "app", map[string]string{
".github/workflows/ci.yml": "name: CI\non: push\n",
})
@@ -406,7 +406,7 @@ func (s stubSecrets) Secrets(context.Context, int64) (map[string]string, error)
func TestDispatchInjectsSecrets(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
- r, head := seedRepo(t, md, s, "atmin/app", map[string]string{
+ r, head := seedRepo(t, md, s, "app", map[string]string{
".github/workflows/ci.yml": "name: CI\non: push\n",
})
@@ -427,7 +427,7 @@ func TestDispatchInjectsSecrets(t *testing.T) {
t.Errorf("GITMOTE_CI_SECRET_API_TOKEN = %q, want the injected value", env["GITMOTE_CI_SECRET_API_TOKEN"])
}
// The namespacing keeps a secret from ever shadowing the runner's coordinates.
- if env["GITMOTE_URL"] != "https://gitmote.test" || env["GITMOTE_REPO"] != "atmin/app" {
+ if env["GITMOTE_URL"] != "https://gitmote.test" || env["GITMOTE_REPO"] != "app" {
t.Errorf("coordinates altered by secret injection: %+v", env)
}
}
@@ -435,7 +435,7 @@ func TestDispatchInjectsSecrets(t *testing.T) {
func TestDispatchSecretsFailureRunsWithout(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
- r, head := seedRepo(t, md, s, "atmin/app", map[string]string{
+ r, head := seedRepo(t, md, s, "app", map[string]string{
".github/workflows/ci.yml": "name: CI\non: push\n",
})
@@ -451,7 +451,7 @@ func TestDispatchSecretsFailureRunsWithout(t *testing.T) {
if len(tr.calls) != 1 {
t.Fatalf("trigger calls = %d, want 1 (secrets failure must not abort dispatch)", len(tr.calls))
}
- if tr.calls[0]["GITMOTE_REPO"] != "atmin/app" {
+ if tr.calls[0]["GITMOTE_REPO"] != "app" {
t.Errorf("job env missing fixed coords: %+v", tr.calls[0])
}
}
diff --git a/internal/ci/report_test.go b/internal/ci/report_test.go
index b2349cb..5a77b3d 100644
--- a/internal/ci/report_test.go
+++ b/internal/ci/report_test.go
@@ -40,7 +40,7 @@ func newReportFixture(t *testing.T, isLeader func() bool) reportFixture {
t.Cleanup(func() { _ = md.Close() })
s := store.NewMem()
- r, err := md.CreateRepo(ctx, "atmin/app", "main")
+ r, err := md.CreateRepo(ctx, "app", "main")
if err != nil {
t.Fatalf("CreateRepo: %v", err)
}
@@ -92,7 +92,7 @@ func TestClaimTransitionsAndReturnsSpec(t *testing.T) {
if err := json.NewDecoder(resp.Body).Decode(&spec); err != nil {
t.Fatalf("decode spec: %v", err)
}
- if spec.JobID != f.jobID || spec.RunID != f.runID || spec.Repo != "atmin/app" ||
+ if spec.JobID != f.jobID || spec.RunID != f.runID || spec.Repo != "app" ||
spec.SHA != "deadbeef" || spec.Ref != "refs/heads/main" || spec.WorkflowDir != workflowDir {
t.Errorf("spec = %+v, want the seeded job's coordinates", spec)
}
diff --git a/internal/githttp/backend.go b/internal/githttp/backend.go
index 8559d73..2675451 100644
--- a/internal/githttp/backend.go
+++ b/internal/githttp/backend.go
@@ -147,7 +147,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// pre-receive hook wired to call back on the socket. The hook's callback does
// the object PUT + ref CAS (see Writer.handle).
func (h *Handler) serveReceivePack(w http.ResponseWriter, r *http.Request, repoName string, pusherID int64) {
- push, err := h.writer.Begin(r.Context(), repoName)
+ push, err := h.writer.Begin(r.Context(), repoName, pusherID)
if err != nil {
if errors.Is(err, meta.ErrNotFound) {
http.NotFound(w, r)
diff --git a/internal/githttp/backend_test.go b/internal/githttp/backend_test.go
index 3e6900b..1509d94 100644
--- a/internal/githttp/backend_test.go
+++ b/internal/githttp/backend_test.go
@@ -18,13 +18,15 @@ import (
"github.com/atmin/gitmote/internal/store"
)
-const repoName = "atmin/dotfiles"
+const repoName = "dotfiles"
// git runs a git command hermetically and returns its trimmed combined output,
// failing the test on a nonzero exit.
func git(t *testing.T, dir string, args ...string) string {
t.Helper()
- cmd := exec.CommandContext(context.Background(), "git", args...)
+ // Disable any credential helper so a token in one URL is never cached and
+ // silently reused by a later "anonymous" request to the same host:port.
+ cmd := exec.CommandContext(context.Background(), "git", append([]string{"-c", "credential.helper="}, args...)...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
@@ -269,13 +271,13 @@ func TestParseGitPath(t *testing.T) {
wantEndpoint string
wantOK bool
}{
- {"/atmin/dotfiles/info/refs", "atmin/dotfiles", "info/refs", true},
- {"/atmin/dotfiles/git-upload-pack", "atmin/dotfiles", "git-upload-pack", true},
- {"/atmin/dotfiles/git-receive-pack", "atmin/dotfiles", "git-receive-pack", true},
+ {"/dotfiles/info/refs", "dotfiles", "info/refs", true},
+ {"/dotfiles/git-upload-pack", "dotfiles", "git-upload-pack", true},
+ {"/dotfiles/git-receive-pack", "dotfiles", "git-receive-pack", true},
{"/one/info/refs", "one", "info/refs", true},
- {"/info/refs", "", "", false}, // no repo
- {"/git-upload-pack", "", "", false}, // no repo
- {"/atmin/dotfiles/HEAD", "", "", false}, // not a served endpoint
+ {"/info/refs", "", "", false}, // no repo
+ {"/git-upload-pack", "", "", false}, // no repo
+ {"/dotfiles/HEAD", "", "", false}, // not a served endpoint
{"/", "", "", false},
}
for _, c := range cases {
diff --git a/internal/githttp/receive.go b/internal/githttp/receive.go
index 339b2ab..34f2750 100644
--- a/internal/githttp/receive.go
+++ b/internal/githttp/receive.go
@@ -72,11 +72,16 @@ type CommitInfo struct {
// dispatch can't hold the push handler open indefinitely.
const afterCommitTimeout = 30 * time.Second
-// pushOp is the parent-side context a nonce resolves to.
+// pushOp is the parent-side context a nonce resolves to. defaultBranch and
+// pusherIsAdmin are the inputs to the default-branch force-push guard
+// (docs/architecture/urls.md): a force-push or deletion of the default branch is
+// admin-only, so the guard needs the branch to protect and the pusher's level.
type pushOp struct {
- repoID int64
- repoName string
- outcome *pushOutcome
+ repoID int64
+ repoName string
+ defaultBranch string
+ pusherIsAdmin bool
+ outcome *pushOutcome
}
// pushOutcome collects the refs a push committed. The hook callback (Writer.handle)
@@ -166,18 +171,31 @@ func (p *Push) Release() { p.release() }
func (p *Push) Committed() []CommitInfo { return p.outcome.committed() }
// Begin starts a push: it resolves the repo, takes the per-repo write lock (so
-// pushes to one repo serialize), and mints a nonce bound to this operation. The
-// caller must Release the returned Push. Begin returns meta.ErrNotFound for an
-// unknown repo.
-func (w *Writer) Begin(ctx context.Context, repoName string) (*Push, error) {
+// pushes to one repo serialize), and mints a nonce bound to this operation. It
+// also resolves the pusher's ACL level, so the hook callback can apply the
+// admin-only default-branch force-push guard. The caller must Release the
+// returned Push. Begin returns meta.ErrNotFound for an unknown repo.
+func (w *Writer) Begin(ctx context.Context, repoName string, pusherID int64) (*Push, error) {
repo, err := w.meta.GetRepo(ctx, repoName)
if err != nil {
return nil, err
}
+ // The pusher already holds ≥write (Authorize passed); resolve whether they
+ // also hold admin, which the force-push guard requires for the default branch.
+ perm, err := w.meta.GetACL(ctx, repo.ID, pusherID)
+ if err != nil && !errors.Is(err, meta.ErrNotFound) {
+ return nil, err
+ }
lk := w.lockRepo(repoName)
lk.Lock()
outcome := &pushOutcome{}
- nonce, err := w.register(pushOp{repoID: repo.ID, repoName: repoName, outcome: outcome})
+ nonce, err := w.register(pushOp{
+ repoID: repo.ID,
+ repoName: repoName,
+ defaultBranch: repo.DefaultBranch,
+ pusherIsAdmin: perm == meta.PermAdmin,
+ outcome: outcome,
+ })
if err != nil {
lk.Unlock()
return nil, err
@@ -245,6 +263,19 @@ func (w *Writer) handle(req hookrpc.Request) hookrpc.Response {
return hookrpc.Response{Reason: "invalid or expired nonce"}
}
+ // Default-branch protection: a force-push or deletion of the default branch is
+ // admin-only. Reject per-ref before uploading anything, so the client sees a
+ // clean refusal (not a post-hoc CAS failure) and no objects are wasted.
+ if !op.pusherIsAdmin {
+ protected := "refs/heads/" + op.defaultBranch
+ for _, c := range req.Commands {
+ if c.Ref == protected && c.Force {
+ return hookrpc.Response{Reason: fmt.Sprintf(
+ "force-push or deletion of the default branch %q requires admin", op.defaultBranch)}
+ }
+ }
+ }
+
ctx, cancel := context.WithTimeout(context.Background(), casTimeout)
defer cancel()
diff --git a/internal/githttp/receive_test.go b/internal/githttp/receive_test.go
index 9cf70e5..ec4bd7c 100644
--- a/internal/githttp/receive_test.go
+++ b/internal/githttp/receive_test.go
@@ -400,7 +400,9 @@ func TestAfterCommitPanicLeavesPushGreen(t *testing.T) {
// tryGit runs a git command hermetically and returns its combined output and
// error without failing the test — for operations expected to be rejected.
func tryGit(dir string, args ...string) (string, error) {
- cmd := exec.CommandContext(context.Background(), "git", args...)
+ // Disable any credential helper so a token from an earlier authed push is not
+ // cached and reused, keeping an "anonymous" request genuinely anonymous.
+ cmd := exec.CommandContext(context.Background(), "git", append([]string{"-c", "credential.helper="}, args...)...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
@@ -622,6 +624,130 @@ func TestAtomicMultiRefRollback(t *testing.T) {
}
}
+// TestDefaultBranchForcePushGuard is the admin-only default-branch protection: a
+// write collaborator may fast-forward the default branch and force-push a
+// non-default branch, but a non-fast-forward of the default branch is refused;
+// an admin may force-push it. (Golden + failure both, per CONTRIBUTING.)
+func TestDefaultBranchForcePushGuard(t *testing.T) {
+ ctx := context.Background()
+ srv, m, _, _ := newWriteServer(t)
+ r, _ := m.CreateRepo(ctx, repoName, "main")
+ writeRaw := mintUser(t, m, r.ID, "collab", meta.PermWrite)
+ adminRaw := mintUser(t, m, r.ID, "boss", meta.PermAdmin)
+ writeRemote := authedURL(srv.URL, writeRaw) + "/" + repoName
+ adminRemote := authedURL(srv.URL, adminRaw) + "/" + repoName
+
+ // The collaborator establishes main (a create) and fast-forwards it — allowed.
+ src := initCommit(t, "one\n")
+ base := git(t, src, "rev-parse", "HEAD")
+ git(t, src, "push", writeRemote, "main")
+ if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("one\ntwo\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, src, "commit", "-am", "ff")
+ git(t, src, "push", writeRemote, "main")
+ ff := git(t, src, "rev-parse", "HEAD")
+
+ // The collaborator rewrites history off base and force-pushes the DEFAULT
+ // branch — refused; main stays at the fast-forward tip.
+ git(t, src, "reset", "--hard", base)
+ if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("one\nrewritten\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, src, "commit", "-am", "rewrite")
+ rewritten := git(t, src, "rev-parse", "HEAD")
+ if out, err := tryGit(src, "push", "--force", writeRemote, "main"); err == nil {
+ t.Fatalf("collaborator force-push of the default branch succeeded, want refusal\n%s", out)
+ }
+ if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != ff {
+ t.Errorf("main after refused force = %q, want unchanged %q", got, ff)
+ }
+
+ // The same rewrite is fine on a NON-default branch: create feature at the
+ // fast-forward tip, then force-push the rewrite over it — allowed.
+ git(t, src, "push", writeRemote, ff+":refs/heads/feature")
+ if out, err := tryGit(src, "push", "--force", writeRemote, "HEAD:refs/heads/feature"); err != nil {
+ t.Fatalf("collaborator force-push of a non-default branch failed, want ok: %v\n%s", err, out)
+ }
+ if got := serverRefSHA(t, m, r.ID, "refs/heads/feature"); got != rewritten {
+ t.Errorf("feature after force = %q, want %q", got, rewritten)
+ }
+
+ // An admin may force-push the default branch.
+ if out, err := tryGit(src, "push", "--force", adminRemote, "main"); err != nil {
+ t.Fatalf("admin force-push of the default branch failed, want ok: %v\n%s", err, out)
+ }
+ if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != rewritten {
+ t.Errorf("main after admin force = %q, want %q", got, rewritten)
+ }
+}
+
+// TestPublicRepoAnonymousReadWriteRefused proves visibility on the transport: a
+// public repo clones with no credentials, but an anonymous push is refused.
+func TestPublicRepoAnonymousReadWriteRefused(t *testing.T) {
+ ctx := context.Background()
+ srv, m, _, _ := newWriteServer(t)
+ r, _ := m.CreateRepo(ctx, repoName, "main")
+ if err := m.SetVisibility(ctx, r.ID, meta.VisibilityPublic); err != nil {
+ t.Fatalf("SetVisibility: %v", err)
+ }
+ writeRaw := mintUser(t, m, r.ID, "collab", meta.PermWrite)
+
+ // A collaborator seeds main.
+ src := initCommit(t, "hello\n")
+ git(t, src, "push", authedURL(srv.URL, writeRaw)+"/"+repoName, "main")
+ head := git(t, src, "rev-parse", "HEAD")
+
+ // Anonymous clone (no credentials) succeeds and matches.
+ dst := filepath.Join(t.TempDir(), "clone")
+ git(t, "", "clone", srv.URL+"/"+repoName, dst)
+ if got := git(t, dst, "rev-parse", "HEAD"); got != head {
+ t.Errorf("anon clone HEAD = %q, want %q", got, head)
+ }
+
+ // Anonymous push is refused, main unchanged.
+ if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello\nmore\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, src, "commit", "-am", "next")
+ if out, err := tryGit(src, "push", srv.URL+"/"+repoName, "main"); err == nil {
+ t.Fatalf("anonymous push to a public repo succeeded, want refusal\n%s", out)
+ }
+ if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != head {
+ t.Errorf("main after refused anon push = %q, want unchanged %q", got, head)
+ }
+}
+
+// TestPrivateRepoSpectatorCannotPush proves the spectator (read ACL) can clone a
+// private repo but not push it.
+func TestPrivateRepoSpectatorCannotPush(t *testing.T) {
+ ctx := context.Background()
+ srv, m, _, _ := newWriteServer(t)
+ r, _ := m.CreateRepo(ctx, repoName, "main") // private by default
+ writeRaw := mintUser(t, m, r.ID, "collab", meta.PermWrite)
+ readRaw := mintUser(t, m, r.ID, "spectator", meta.PermRead)
+
+ src := initCommit(t, "hello\n")
+ git(t, src, "push", authedURL(srv.URL, writeRaw)+"/"+repoName, "main")
+ head := git(t, src, "rev-parse", "HEAD")
+
+ // The spectator can clone (read ACL on a private repo).
+ dst := filepath.Join(t.TempDir(), "clone")
+ git(t, "", "clone", authedURL(srv.URL, readRaw)+"/"+repoName, dst)
+
+ // But cannot push.
+ if err := os.WriteFile(filepath.Join(dst, "file.txt"), []byte("hello\nedit\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, dst, "commit", "-am", "spectator edit")
+ if out, err := tryGit(dst, "push", authedURL(srv.URL, readRaw)+"/"+repoName, "main"); err == nil {
+ t.Fatalf("spectator push succeeded, want refusal\n%s", out)
+ }
+ if got := serverRefSHA(t, m, r.ID, "refs/heads/main"); got != head {
+ t.Errorf("main after refused spectator push = %q, want unchanged %q", got, head)
+ }
+}
+
func TestOrderingInvariantObjectsBeforeRef(t *testing.T) {
ctx := context.Background()
srv, m, s, writer := newWriteServer(t)
diff --git a/internal/hookrpc/hookrpc.go b/internal/hookrpc/hookrpc.go
index b26c1ca..2aec63b 100644
--- a/internal/hookrpc/hookrpc.go
+++ b/internal/hookrpc/hookrpc.go
@@ -23,10 +23,15 @@ const (
// RefCommand is one `<old> <new> <ref>` line from the hook's stdin. old/new are
// object ids; the all-zero id means "absent" (create when old, delete when new).
+// Force is set by the hook when the update rewrites history — a deletion, or a
+// non-fast-forward (old is not an ancestor of new). The parent uses it to gate
+// force-pushes of the default branch to admins (docs/architecture/urls.md); the
+// hook computes it because only it sits in the repo with the objects present.
type RefCommand struct {
- Old string `json:"old"`
- New string `json:"new"`
- Ref string `json:"ref"`
+ Old string `json:"old"`
+ New string `json:"new"`
+ Ref string `json:"ref"`
+ Force bool `json:"force"`
}
// Request is the hook → parent message: the nonce, the ref commands, and the
diff --git a/internal/meta/acls.go b/internal/meta/acls.go
index d7b9e0d..fd9f5e7 100644
--- a/internal/meta/acls.go
+++ b/internal/meta/acls.go
@@ -1,6 +1,9 @@
package meta
-import "context"
+import (
+ "context"
+ "errors"
+)
// Perm is a repository access level, ordered read < write < admin. It matches
// the CHECK constraint on acls.perm.
@@ -63,6 +66,27 @@ func (m *Metadata) DeleteACL(ctx context.Context, repoID, userID int64) error {
return err
}
+// CanRead reports whether a viewer may read repo: a public repo is readable by
+// anyone (userID 0 = anonymous); a private repo needs any ACL grant (read,
+// write, or admin). It is the visibility-aware repo-read shared by the git-read
+// and browse paths (docs/architecture/urls.md, auth.md).
+func (m *Metadata) CanRead(ctx context.Context, repo *Repo, userID int64) (bool, error) {
+ if repo.Public() {
+ return true, nil
+ }
+ if userID == 0 {
+ return false, nil
+ }
+ _, err := m.GetACL(ctx, repo.ID, userID)
+ if errors.Is(err, ErrNotFound) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ return true, nil
+}
+
// GetACL returns user's permission on repo, or ErrNotFound when none is set
// (no access).
func (m *Metadata) GetACL(ctx context.Context, repoID, userID int64) (Perm, error) {
diff --git a/internal/meta/ci_test.go b/internal/meta/ci_test.go
index 7393c06..af8d989 100644
--- a/internal/meta/ci_test.go
+++ b/internal/meta/ci_test.go
@@ -9,7 +9,7 @@ import (
func TestCreateRun(t *testing.T) {
ctx := context.Background()
m := open(t)
- r := seedRepo(t, m, "atmin/repo")
+ r := seedRepo(t, m, "repo")
const main = "refs/heads/main"
run, err := m.CreateRun(ctx, r.ID, main, shaA)
@@ -36,7 +36,7 @@ func TestCreateRun(t *testing.T) {
func TestSetRunStatus(t *testing.T) {
ctx := context.Background()
m := open(t)
- r := seedRepo(t, m, "atmin/repo")
+ r := seedRepo(t, m, "repo")
run, err := m.CreateRun(ctx, r.ID, "refs/heads/main", shaA)
if err != nil {
@@ -63,8 +63,8 @@ func TestSetRunStatus(t *testing.T) {
func TestListRunsNewestFirst(t *testing.T) {
ctx := context.Background()
m := open(t)
- r := seedRepo(t, m, "atmin/repo")
- other := seedRepo(t, m, "atmin/other")
+ r := seedRepo(t, m, "repo")
+ other := seedRepo(t, m, "other")
first, _ := m.CreateRun(ctx, r.ID, "refs/heads/main", shaA)
second, _ := m.CreateRun(ctx, r.ID, "refs/heads/main", shaB)
diff --git a/internal/meta/meta_test.go b/internal/meta/meta_test.go
index a6e45bb..5cbc311 100644
--- a/internal/meta/meta_test.go
+++ b/internal/meta/meta_test.go
@@ -40,20 +40,23 @@ func TestMigrationsIdempotentAndFreshSchema(t *testing.T) {
// Fresh DB: schema is usable — a round-trip proves the tables exist.
m := openAt(t, path)
- r, err := m.CreateRepo(ctx, "atmin/dotfiles", "")
+ r, err := m.CreateRepo(ctx, "dotfiles", "")
if err != nil {
t.Fatalf("CreateRepo on fresh schema: %v", err)
}
if r.DefaultBranch != "main" {
t.Errorf("default branch = %q, want main", r.DefaultBranch)
}
+ if r.Visibility != VisibilityPrivate {
+ t.Errorf("visibility = %q, want private", r.Visibility)
+ }
if err := m.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
// Re-open the same file: migrations are a no-op and data survives.
m2 := openAt(t, path)
- got, err := m2.GetRepo(ctx, "atmin/dotfiles")
+ got, err := m2.GetRepo(ctx, "dotfiles")
if err != nil {
t.Fatalf("GetRepo after re-open: %v", err)
}
@@ -103,7 +106,7 @@ func TestScopedTokenColumnsMigrateAndPersist(t *testing.T) {
t.Fatalf("Close: %v", err)
}
m3 := openAt(t, path) // second migrate is a no-op
- r := seedRepo(t, m3, "atmin/repo")
+ r := seedRepo(t, m3, "repo")
exp := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
if _, err := m3.CreateScopedToken(ctx, u.ID, "sel2", "ver2", "scoped", &r.ID, true, exp); err != nil {
t.Fatalf("CreateScopedToken: %v", err)
@@ -126,8 +129,8 @@ func TestRepoCRUD(t *testing.T) {
t.Fatalf("GetRepo(missing) = %v, want ErrNotFound", err)
}
- seedRepo(t, m, "a/one")
- b := seedRepo(t, m, "b/two")
+ seedRepo(t, m, "one")
+ b := seedRepo(t, m, "two")
if b.DefaultBranch != "main" {
t.Errorf("default branch = %q, want main", b.DefaultBranch)
}
@@ -136,16 +139,110 @@ func TestRepoCRUD(t *testing.T) {
if err != nil {
t.Fatalf("ListRepos: %v", err)
}
- if len(repos) != 2 || repos[0].Name != "a/one" || repos[1].Name != "b/two" {
- t.Errorf("ListRepos = %+v, want [a/one b/two]", repos)
+ if len(repos) != 2 || repos[0].Name != "one" || repos[1].Name != "two" {
+ t.Errorf("ListRepos = %+v, want [one two]", repos)
}
// name is UNIQUE.
- if _, err := m.CreateRepo(ctx, "a/one", ""); err == nil {
+ if _, err := m.CreateRepo(ctx, "one", ""); err == nil {
t.Error("CreateRepo(duplicate name) succeeded, want error")
}
}
+// TestRepoNameValidation covers the flat single-segment namespace rules: a
+// trailing ".git" is stripped, reserved globals and structural forms are
+// rejected with a clear error, and a ".git" clone URL resolves via GetRepo.
+func TestRepoNameValidation(t *testing.T) {
+ ctx := context.Background()
+
+ // A trailing ".git" is stripped at creation and tolerated at lookup.
+ m := open(t)
+ r, err := m.CreateRepo(ctx, "gitmote.git", "")
+ if err != nil {
+ t.Fatalf("CreateRepo(gitmote.git): %v", err)
+ }
+ if r.Name != "gitmote" {
+ t.Errorf("normalized name = %q, want gitmote", r.Name)
+ }
+ if got, err := m.GetRepo(ctx, "gitmote.git"); err != nil || got.ID != r.ID {
+ t.Errorf("GetRepo(gitmote.git) = (%v, %v), want the same repo", got, err)
+ }
+
+ // Rejected names: reserved globals, a leading dot, the bare dash, a slash, empty.
+ for _, name := range []string{"login", "api", "internal", ".hidden", "-", "a/b", ""} {
+ if _, err := m.CreateRepo(ctx, name, ""); err == nil {
+ t.Errorf("CreateRepo(%q) succeeded, want a validation error", name)
+ }
+ }
+}
+
+// TestSetVisibility flips a repo public and back, and rejects an invalid value.
+func TestSetVisibility(t *testing.T) {
+ ctx := context.Background()
+ m := open(t)
+ r := seedRepo(t, m, "repo")
+ if r.Public() {
+ t.Fatal("new repo should default to private")
+ }
+
+ if err := m.SetVisibility(ctx, r.ID, VisibilityPublic); err != nil {
+ t.Fatalf("SetVisibility(public): %v", err)
+ }
+ got, _ := m.GetRepo(ctx, "repo")
+ if !got.Public() {
+ t.Errorf("visibility = %q, want public", got.Visibility)
+ }
+
+ // The CHECK constraint rejects an unknown value.
+ if err := m.SetVisibility(ctx, r.ID, "secret"); err == nil {
+ t.Error("SetVisibility(secret) succeeded, want a CHECK violation")
+ }
+ // Unknown repo id.
+ if err := m.SetVisibility(ctx, 9999, VisibilityPublic); !errors.Is(err, ErrNotFound) {
+ t.Errorf("SetVisibility(unknown id) = %v, want ErrNotFound", err)
+ }
+}
+
+// TestCanRead covers the visibility-aware repo-read shared by git and browse:
+// a public repo is anyone-readable, a private repo needs an ACL.
+func TestCanRead(t *testing.T) {
+ ctx := context.Background()
+ m := open(t)
+ priv := seedRepo(t, m, "priv")
+ pub := seedRepo(t, m, "pub")
+ if err := m.SetVisibility(ctx, pub.ID, VisibilityPublic); err != nil {
+ t.Fatalf("SetVisibility: %v", err)
+ }
+ pub, _ = m.GetRepo(ctx, "pub")
+
+ u, _ := m.CreateUser(ctx, "spectator")
+
+ cases := []struct {
+ name string
+ repo *Repo
+ userID int64
+ want bool
+ }{
+ {"public anonymous", pub, 0, true},
+ {"public user", pub, u.ID, true},
+ {"private anonymous", priv, 0, false},
+ {"private no ACL", priv, u.ID, false},
+ }
+ for _, c := range cases {
+ if got, err := m.CanRead(ctx, c.repo, c.userID); err != nil || got != c.want {
+ t.Errorf("%s: CanRead = (%v, %v), want %v", c.name, got, err, c.want)
+ }
+ }
+
+ // A read ACL opens the private repo.
+ if err := m.SetACL(ctx, priv.ID, u.ID, PermRead); err != nil {
+ t.Fatalf("SetACL: %v", err)
+ }
+ if got, err := m.CanRead(ctx, priv, u.ID); err != nil || !got {
+ t.Errorf("private with read ACL: CanRead = (%v, %v), want true", got, err)
+ }
+}
+
func TestTokenStorage(t *testing.T) {
ctx := context.Background()
m := open(t)
@@ -206,7 +303,7 @@ func TestACLLookup(t *testing.T) {
ctx := context.Background()
m := open(t)
- r := seedRepo(t, m, "atmin/repo")
+ r := seedRepo(t, m, "repo")
u, err := m.CreateUser(ctx, "atmin")
if err != nil {
t.Fatalf("CreateUser: %v", err)
diff --git a/internal/meta/refs_test.go b/internal/meta/refs_test.go
index 282592f..11f42bd 100644
--- a/internal/meta/refs_test.go
+++ b/internal/meta/refs_test.go
@@ -30,7 +30,7 @@ func refSHA(t *testing.T, m *Metadata, repoID int64, name string) string {
func TestCASRefCreateUpdateDelete(t *testing.T) {
ctx := context.Background()
m := open(t)
- r := seedRepo(t, m, "atmin/repo")
+ r := seedRepo(t, m, "repo")
const main = "refs/heads/main"
// Create: old is the zero id (ref must be absent).
@@ -61,7 +61,7 @@ func TestCASRefCreateUpdateDelete(t *testing.T) {
func TestCASRefRejectsAndRollsBack(t *testing.T) {
ctx := context.Background()
m := open(t)
- r := seedRepo(t, m, "atmin/repo")
+ r := seedRepo(t, m, "repo")
const main = "refs/heads/main"
if err := m.CASRef(ctx, r.ID, main, ZeroSHA, shaA); err != nil {
@@ -91,7 +91,7 @@ func TestCASRefRejectsAndRollsBack(t *testing.T) {
func TestCASRefsAtomicMultiRef(t *testing.T) {
ctx := context.Background()
m := open(t)
- r := seedRepo(t, m, "atmin/repo")
+ r := seedRepo(t, m, "repo")
const (
main = "refs/heads/main"
dev = "refs/heads/dev"
diff --git a/internal/meta/repos.go b/internal/meta/repos.go
index 236cc73..8408cef 100644
--- a/internal/meta/repos.go
+++ b/internal/meta/repos.go
@@ -2,27 +2,84 @@ package meta
import (
"context"
+ "fmt"
+ "strings"
"time"
)
+// Visibility values for repos.visibility. A public repo is readable with no
+// token (clone/fetch/browse); writes are never anonymous (see auth.md).
+const (
+ VisibilityPrivate = "private"
+ VisibilityPublic = "public"
+)
+
+// ReservedRepoNames are the top-level global route names a repo may not take,
+// so a repo can never shadow a global route. Kept here as the single source so
+// routing and CreateRepo validation cannot drift (docs/architecture/urls.md).
+// The `.`-prefix and bare-`-` structural rules (below, in validateRepoName)
+// reserve room for future system routes without a breaking rename.
+var ReservedRepoNames = map[string]bool{
+ "login": true, "logout": true, "users": true, "tokens": true,
+ "settings": true, "new": true, "search": true, "api": true,
+ "metrics": true, "internal": true, "static": true, "healthz": true,
+ "version": true,
+}
+
// Repo is a hosted repository. DefaultBranch backs the derived HEAD (see
-// storage.md — HEAD is not a stored ref).
+// storage.md — HEAD is not a stored ref). Visibility is "private" or "public".
type Repo struct {
ID int64
Name string
DefaultBranch string
+ Visibility string
CreatedAt time.Time
}
-// CreateRepo inserts a repository. defaultBranch defaults to "main" when empty.
+// Public reports whether the repo is readable anonymously.
+func (r *Repo) Public() bool { return r.Visibility == VisibilityPublic }
+
+// normalizeRepoName strips a trailing ".git" so `clone host/<repo>.git`
+// resolves to the same repo as `<repo>` (both at creation and lookup).
+func normalizeRepoName(name string) string {
+ return strings.TrimSuffix(name, ".git")
+}
+
+// validateRepoName enforces the flat single-segment namespace: a name is one
+// path segment, not a reserved global, not starting with "." and not the bare
+// "-" (nor empty). See docs/architecture/urls.md. The name is assumed already
+// normalized (trailing ".git" stripped).
+func validateRepoName(name string) error {
+ switch {
+ case name == "":
+ return fmt.Errorf("repo name cannot be empty")
+ case strings.Contains(name, "/"):
+ return fmt.Errorf("repo name %q cannot contain %q (repos are a single path segment)", name, "/")
+ case strings.HasPrefix(name, "."):
+ return fmt.Errorf("repo name %q cannot start with %q", name, ".")
+ case name == "-":
+ return fmt.Errorf("repo name cannot be %q", "-")
+ case ReservedRepoNames[name]:
+ return fmt.Errorf("repo name %q is reserved", name)
+ }
+ return nil
+}
+
+// CreateRepo inserts a repository at visibility "private". A trailing ".git" is
+// stripped and the name is validated against the flat namespace rules
+// (validateRepoName). defaultBranch defaults to "main" when empty.
func (m *Metadata) CreateRepo(ctx context.Context, name, defaultBranch string) (*Repo, error) {
+ name = normalizeRepoName(name)
+ if err := validateRepoName(name); err != nil {
+ return nil, err
+ }
if defaultBranch == "" {
defaultBranch = "main"
}
created := now()
res, err := m.db.ExecContext(ctx,
- `INSERT INTO repos (name, default_branch, created_at) VALUES (?, ?, ?)`,
- name, defaultBranch, created)
+ `INSERT INTO repos (name, default_branch, visibility, created_at) VALUES (?, ?, ?, ?)`,
+ name, defaultBranch, VisibilityPrivate, created)
if err != nil {
return nil, err
}
@@ -30,7 +87,32 @@ func (m *Metadata) CreateRepo(ctx context.Context, name, defaultBranch string) (
if err != nil {
return nil, err
}
- return &Repo{ID: id, Name: name, DefaultBranch: defaultBranch, CreatedAt: parseTime(created)}, nil
+ return &Repo{
+ ID: id,
+ Name: name,
+ DefaultBranch: defaultBranch,
+ Visibility: VisibilityPrivate,
+ CreatedAt: parseTime(created),
+ }, nil
+}
+
+// SetVisibility sets a repository's visibility ("private" or "public"). It
+// returns ErrNotFound when no repo has the given id; the CHECK constraint
+// rejects any other value.
+func (m *Metadata) SetVisibility(ctx context.Context, repoID int64, visibility string) error {
+ res, err := m.db.ExecContext(ctx,
+ `UPDATE repos SET visibility = ? WHERE id = ?`, visibility, repoID)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return ErrNotFound
+ }
+ return nil
}
// SetDefaultBranch updates a repository's default branch (the derived HEAD). It
@@ -51,15 +133,17 @@ func (m *Metadata) SetDefaultBranch(ctx context.Context, repoID int64, branch st
return nil
}
-// GetRepo returns the repository named name, or ErrNotFound.
+// GetRepo returns the repository named name, or ErrNotFound. A trailing ".git"
+// is tolerated (stripped) so `clone host/<repo>.git` resolves.
func (m *Metadata) GetRepo(ctx context.Context, name string) (*Repo, error) {
var (
r Repo
ts string
)
err := m.db.QueryRowContext(ctx,
- `SELECT id, name, default_branch, created_at FROM repos WHERE name = ?`, name).
- Scan(&r.ID, &r.Name, &r.DefaultBranch, &ts)
+ `SELECT id, name, default_branch, visibility, created_at FROM repos WHERE name = ?`,
+ normalizeRepoName(name)).
+ Scan(&r.ID, &r.Name, &r.DefaultBranch, &r.Visibility, &ts)
if isNoRows(err) {
return nil, ErrNotFound
}
@@ -77,8 +161,8 @@ func (m *Metadata) GetRepoByID(ctx context.Context, id int64) (*Repo, error) {
ts string
)
err := m.db.QueryRowContext(ctx,
- `SELECT id, name, default_branch, created_at FROM repos WHERE id = ?`, id).
- Scan(&r.ID, &r.Name, &r.DefaultBranch, &ts)
+ `SELECT id, name, default_branch, visibility, created_at FROM repos WHERE id = ?`, id).
+ Scan(&r.ID, &r.Name, &r.DefaultBranch, &r.Visibility, &ts)
if isNoRows(err) {
return nil, ErrNotFound
}
@@ -92,7 +176,7 @@ func (m *Metadata) GetRepoByID(ctx context.Context, id int64) (*Repo, error) {
// ListRepos returns all repositories ordered by name.
func (m *Metadata) ListRepos(ctx context.Context) ([]Repo, error) {
rows, err := m.db.QueryContext(ctx,
- `SELECT id, name, default_branch, created_at FROM repos ORDER BY name`)
+ `SELECT id, name, default_branch, visibility, created_at FROM repos ORDER BY name`)
if err != nil {
return nil, err
}
@@ -104,7 +188,7 @@ func (m *Metadata) ListRepos(ctx context.Context) ([]Repo, error) {
r Repo
ts string
)
- if err := rows.Scan(&r.ID, &r.Name, &r.DefaultBranch, &ts); err != nil {
+ if err := rows.Scan(&r.ID, &r.Name, &r.DefaultBranch, &r.Visibility, &ts); err != nil {
return nil, err
}
r.CreatedAt = parseTime(ts)
diff --git a/internal/meta/schema.sql b/internal/meta/schema.sql
index 37ee74d..c05bd99 100644
--- a/internal/meta/schema.sql
+++ b/internal/meta/schema.sql
@@ -4,8 +4,10 @@
CREATE TABLE IF NOT EXISTS repos (
id INTEGER PRIMARY KEY,
- name TEXT NOT NULL UNIQUE, -- "atmin/dotfiles"
+ name TEXT NOT NULL UNIQUE, -- single path segment, e.g. "gitmote"
default_branch TEXT NOT NULL DEFAULT 'main',
+ visibility TEXT NOT NULL DEFAULT 'private' -- 'private' | 'public'; public = read-anonymous
+ CHECK (visibility IN ('private','public')),
created_at TEXT NOT NULL
);
diff --git a/internal/meta/ui_test.go b/internal/meta/ui_test.go
index 6393dba..43a5bdd 100644
--- a/internal/meta/ui_test.go
+++ b/internal/meta/ui_test.go
@@ -9,12 +9,12 @@ import (
func TestSetDefaultBranch(t *testing.T) {
ctx := context.Background()
m := open(t)
- r := seedRepo(t, m, "atmin/app")
+ r := seedRepo(t, m, "app")
if err := m.SetDefaultBranch(ctx, r.ID, "trunk"); err != nil {
t.Fatalf("SetDefaultBranch: %v", err)
}
- got, err := m.GetRepo(ctx, "atmin/app")
+ got, err := m.GetRepo(ctx, "app")
if err != nil {
t.Fatalf("GetRepo: %v", err)
}
@@ -93,7 +93,7 @@ func TestDeleteToken(t *testing.T) {
func TestListAndDeleteACL(t *testing.T) {
ctx := context.Background()
m := open(t)
- r := seedRepo(t, m, "atmin/app")
+ r := seedRepo(t, m, "app")
alice, _ := m.CreateUser(ctx, "alice")
bob, _ := m.CreateUser(ctx, "bob")
if err := m.SetACL(ctx, r.ID, bob.ID, PermWrite); err != nil {
diff --git a/internal/repo/materialize_test.go b/internal/repo/materialize_test.go
index f1bda57..0f59cf7 100644
--- a/internal/repo/materialize_test.go
+++ b/internal/repo/materialize_test.go
@@ -123,7 +123,7 @@ func TestMaterialize(t *testing.T) {
ctx := context.Background()
m := openMeta(t)
s := store.NewMem()
- const repoName = "atmin/dotfiles"
+ const repoName = "dotfiles"
branch, head := seedRepo(t, m, s, repoName, tc.repack)
mz := New(m, s, t.TempDir())
@@ -192,7 +192,7 @@ func TestMaterializeRefsNoObjects(t *testing.T) {
ctx := context.Background()
m := openMeta(t)
s := &spyStore{Store: store.NewMem()}
- const repoName = "atmin/dotfiles"
+ const repoName = "dotfiles"
branch, head := seedRepo(t, m, s, repoName, false)
// An annotated tag: meta points the ref at the tag object, which is never
@@ -243,20 +243,20 @@ func TestMaterializeRefsNoObjects(t *testing.T) {
func TestMaterializeUnknownRepo(t *testing.T) {
mz := New(openMeta(t), store.NewMem(), t.TempDir())
- if _, err := mz.Materialize(context.Background(), "nope/nope"); !errors.Is(err, meta.ErrNotFound) {
+ if _, err := mz.Materialize(context.Background(), "nope"); !errors.Is(err, meta.ErrNotFound) {
t.Fatalf("Materialize(unknown) = %v, want meta.ErrNotFound", err)
}
}
-func TestMaterializeRejectsUnsafeName(t *testing.T) {
- ctx := context.Background()
- m := openMeta(t)
- // A repo whose name would escape the cache root must never materialize.
- if _, err := m.CreateRepo(ctx, "../evil", "main"); err != nil {
- t.Fatalf("CreateRepo: %v", err)
- }
- mz := New(m, store.NewMem(), t.TempDir())
- if _, err := mz.Materialize(ctx, "../evil"); err == nil {
- t.Fatal("Materialize(../evil) succeeded, want unsafe-name error")
+// TestRepoDirRejectsUnsafeName guards the cache-root escape defense directly:
+// CreateRepo now forbids "/" in a name, so such a name can't reach the
+// materializer through meta, but repoDir stays defense-in-depth against any name
+// that would resolve outside the cache root.
+func TestRepoDirRejectsUnsafeName(t *testing.T) {
+ mz := New(openMeta(t), store.NewMem(), t.TempDir())
+ for _, name := range []string{"../evil", "/etc/passwd", "a/../../b", ""} {
+ if _, err := mz.repoDir(name); err == nil {
+ t.Errorf("repoDir(%q) succeeded, want unsafe-name error", name)
+ }
}
}
diff --git a/internal/secrets/service_test.go b/internal/secrets/service_test.go
index 12a2bab..daeeac8 100644
--- a/internal/secrets/service_test.go
+++ b/internal/secrets/service_test.go
@@ -24,7 +24,7 @@ func newSvc(t *testing.T) (*Service, int64) {
t.Fatalf("meta.Open: %v", err)
}
t.Cleanup(func() { _ = md.Close() })
- r, err := md.CreateRepo(context.Background(), "atmin/app", "main")
+ r, err := md.CreateRepo(context.Background(), "app", "main")
if err != nil {
t.Fatalf("CreateRepo: %v", err)
}
diff --git a/internal/webui/browse.go b/internal/webui/browse.go
index 995d375..a8394e1 100644
--- a/internal/webui/browse.go
+++ b/internal/webui/browse.go
@@ -29,6 +29,9 @@ func (h *Handler) browse(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
+ if !h.authorizeRead(w, r, repoName) {
+ return
+ }
action, arg, _ := strings.Cut(tail, "/")
switch action {
case "tree":
@@ -50,6 +53,51 @@ func (h *Handler) browse(w http.ResponseWriter, r *http.Request) {
}
}
+// authorizeRead gates a browse request on repo-read: a public repo is readable
+// by anyone, a private repo needs a signed-in user with an ACL (CanRead). It
+// writes the response and returns false on refusal — 404 for an unknown repo, a
+// login redirect for an anonymous viewer of a private repo, 403 for a signed-in
+// viewer without access. Management routes stay admin-gated (requireAdmin).
+func (h *Handler) authorizeRead(w http.ResponseWriter, r *http.Request, repoName string) bool {
+ // The viewer is optional here: a valid session resolves to its user, else the
+ // request is anonymous (uid 0), which CanRead allows only for a public repo.
+ var uid int64
+ if id, ok := h.sess.verify(r, h.now()); ok {
+ if u, err := h.md.GetUserByID(r.Context(), id); err == nil {
+ uid = u.ID
+ }
+ }
+ rp, err := h.md.GetRepo(r.Context(), repoName)
+ if errors.Is(err, meta.ErrNotFound) {
+ // Don't reveal a private forge's inventory to anonymous callers: a missing
+ // repo redirects to login rather than 404ing (a signed-in user gets the 404).
+ if uid == 0 {
+ h.denyUnauth(w, r)
+ return false
+ }
+ http.NotFound(w, r)
+ return false
+ }
+ if err != nil {
+ h.serverError(w, "get repo", err)
+ return false
+ }
+ can, err := h.md.CanRead(r.Context(), rp, uid)
+ if err != nil {
+ h.serverError(w, "authorize read", err)
+ return false
+ }
+ if can {
+ return true
+ }
+ if uid == 0 {
+ h.denyUnauth(w, r) // anonymous → login
+ return false
+ }
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return false
+}
+
// browseCtx is the resolved starting point every browse handler needs: the repo
// record, its materialized on-disk dir, the selected ref, and the commit SHA
// that ref points at.
diff --git a/internal/webui/browse_test.go b/internal/webui/browse_test.go
index 6fc0e58..738e8c0 100644
--- a/internal/webui/browse_test.go
+++ b/internal/webui/browse_test.go
@@ -78,6 +78,11 @@ func (x *harness) seedBrowseRepo(name, branch string) (head, first string) {
if err != nil {
x.t.Fatalf("CreateRepo: %v", err)
}
+ // Browse is gated on repo-read; grant the harness admin an ACL so a logged-in
+ // admin can browse this (private-by-default) repo.
+ if err := x.md.SetACL(ctx, r.ID, x.admin.ID, meta.PermAdmin); err != nil {
+ x.t.Fatalf("SetACL: %v", err)
+ }
if err := x.md.CASRef(ctx, r.ID, "refs/heads/"+branch, meta.ZeroSHA, head); err != nil {
x.t.Fatalf("CASRef branch: %v", err)
}
@@ -98,24 +103,60 @@ func write(t *testing.T, dir, rel, content string) {
}
}
-func TestBrowseRequiresAdmin(t *testing.T) {
+// TestBrowseAccessControl exercises the repo-read gate on browse: a private repo
+// is browsable by the admin and a read-ACL spectator, refused (403) for a
+// signed-in stranger, and redirects an anonymous viewer to login; flipping it
+// public opens it to anyone.
+func TestBrowseAccessControl(t *testing.T) {
x := newHarness(t)
- x.seedBrowseRepo("alice/app", "main")
+ ctx := context.Background()
+ x.seedBrowseRepo("app", "main") // private by default; grants the admin an ACL
+ r, _ := x.md.GetRepo(ctx, "app")
+
+ adminSession := x.login(x.mintTokenFor(x.admin.ID))
+
+ // A spectator: a non-admin with a read ACL.
+ spec, _ := x.md.CreateUser(ctx, "spectator")
+ if err := x.md.SetACL(ctx, r.ID, spec.ID, meta.PermRead); err != nil {
+ t.Fatalf("SetACL: %v", err)
+ }
+ specSession := x.sessionFor(spec.ID)
+
+ // A stranger: a signed-in user with no ACL.
+ stranger, _ := x.md.CreateUser(ctx, "stranger")
+ strangerSession := x.sessionFor(stranger.ID)
+
+ const target = "/browse/app/-/tree/"
- // No session cookie: a GET redirects to login rather than serving content.
- rec := x.do(http.MethodGet, "/browse/alice/app/-/tree/", nil, nil)
- if rec.Code != http.StatusSeeOther {
- t.Fatalf("unauth browse = %d, want 303", rec.Code)
+ if rec := x.do(http.MethodGet, target, nil, adminSession); rec.Code != http.StatusOK {
+ t.Errorf("admin browse private = %d, want 200", rec.Code)
+ }
+ if rec := x.do(http.MethodGet, target, nil, specSession); rec.Code != http.StatusOK {
+ t.Errorf("spectator browse private = %d, want 200", rec.Code)
+ }
+ if rec := x.do(http.MethodGet, target, nil, strangerSession); rec.Code != http.StatusForbidden {
+ t.Errorf("stranger browse private = %d, want 403", rec.Code)
+ }
+ if rec := x.do(http.MethodGet, target, nil, nil); rec.Code != http.StatusSeeOther {
+ t.Errorf("anonymous browse private = %d, want 303 (login)", rec.Code)
+ }
+
+ // Make it public: an anonymous viewer may now browse.
+ if err := x.md.SetVisibility(ctx, r.ID, meta.VisibilityPublic); err != nil {
+ t.Fatalf("SetVisibility: %v", err)
+ }
+ if rec := x.do(http.MethodGet, target, nil, nil); rec.Code != http.StatusOK {
+ t.Errorf("anonymous browse public = %d, want 200", rec.Code)
}
}
func TestBrowseTreeAndBlob(t *testing.T) {
x := newHarness(t)
- x.seedBrowseRepo("alice/app", "main")
+ x.seedBrowseRepo("app", "main")
session := x.login(x.mintTokenFor(x.admin.ID))
// Root tree lists the top-level entries.
- rec := x.do(http.MethodGet, "/browse/alice/app/-/tree/", nil, session)
+ rec := x.do(http.MethodGet, "/browse/app/-/tree/", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("tree = %d (%s)", rec.Code, rec.Body)
}
@@ -127,14 +168,14 @@ func TestBrowseTreeAndBlob(t *testing.T) {
}
// Subdirectory tree.
- rec = x.do(http.MethodGet, "/browse/alice/app/-/tree/sub?ref=main", nil, session)
+ rec = x.do(http.MethodGet, "/browse/app/-/tree/sub?ref=main", nil, session)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "note.txt") {
t.Fatalf("sub tree = %d (%s)", rec.Code, rec.Body)
}
// Text blob renders its content (highlighting splits tokens into spans, so
// assert on a single token that survives).
- rec = x.do(http.MethodGet, "/browse/alice/app/-/blob/hello.go?ref=main", nil, session)
+ rec = x.do(http.MethodGet, "/browse/app/-/blob/hello.go?ref=main", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("blob = %d (%s)", rec.Code, rec.Body)
}
@@ -143,7 +184,7 @@ func TestBrowseTreeAndBlob(t *testing.T) {
}
// Binary blob shows the download affordance, not garbage.
- rec = x.do(http.MethodGet, "/browse/alice/app/-/blob/bin.dat?ref=main", nil, session)
+ rec = x.do(http.MethodGet, "/browse/app/-/blob/bin.dat?ref=main", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("binary blob = %d", rec.Code)
}
@@ -154,10 +195,10 @@ func TestBrowseTreeAndBlob(t *testing.T) {
func TestBrowseRawDownload(t *testing.T) {
x := newHarness(t)
- x.seedBrowseRepo("alice/app", "main")
+ x.seedBrowseRepo("app", "main")
session := x.login(x.mintTokenFor(x.admin.ID))
- rec := x.do(http.MethodGet, "/browse/alice/app/-/raw/sub/note.txt?ref=main", nil, session)
+ rec := x.do(http.MethodGet, "/browse/app/-/raw/sub/note.txt?ref=main", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("raw = %d (%s)", rec.Code, rec.Body)
}
@@ -171,10 +212,10 @@ func TestBrowseRawDownload(t *testing.T) {
func TestBrowseRefSwitcher(t *testing.T) {
x := newHarness(t)
- x.seedBrowseRepo("alice/app", "main")
+ x.seedBrowseRepo("app", "main")
session := x.login(x.mintTokenFor(x.admin.ID))
- rec := x.do(http.MethodGet, "/browse/alice/app/-/tree/", nil, session)
+ rec := x.do(http.MethodGet, "/browse/app/-/tree/", nil, session)
body := rec.Body.String()
// The switcher lists both the branch and the tag from meta.ListRefs.
if !strings.Contains(body, `value="main"`) || !strings.Contains(body, `value="v1"`) {
@@ -184,10 +225,10 @@ func TestBrowseRefSwitcher(t *testing.T) {
func TestBrowseCommitsAndCommit(t *testing.T) {
x := newHarness(t)
- head, _ := x.seedBrowseRepo("alice/app", "main")
+ head, _ := x.seedBrowseRepo("app", "main")
session := x.login(x.mintTokenFor(x.admin.ID))
- rec := x.do(http.MethodGet, "/browse/alice/app/-/commits?ref=main", nil, session)
+ rec := x.do(http.MethodGet, "/browse/app/-/commits?ref=main", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("commits = %d (%s)", rec.Code, rec.Body)
}
@@ -196,7 +237,7 @@ func TestBrowseCommitsAndCommit(t *testing.T) {
t.Fatalf("commits body missing subjects:\n%s", body)
}
- rec = x.do(http.MethodGet, "/browse/alice/app/-/commit/"+head, nil, session)
+ rec = x.do(http.MethodGet, "/browse/app/-/commit/"+head, nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("commit = %d (%s)", rec.Code, rec.Body)
}
@@ -207,11 +248,11 @@ func TestBrowseCommitsAndCommit(t *testing.T) {
func TestBrowseHighlightAndMarkdown(t *testing.T) {
x := newHarness(t)
- x.seedBrowseRepo("alice/app", "main")
+ x.seedBrowseRepo("app", "main")
session := x.login(x.mintTokenFor(x.admin.ID))
// A .go blob is syntax-highlighted: chroma class spans, not a bare <pre>.
- rec := x.do(http.MethodGet, "/browse/alice/app/-/blob/hello.go?ref=main", nil, session)
+ rec := x.do(http.MethodGet, "/browse/app/-/blob/hello.go?ref=main", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("go blob = %d (%s)", rec.Code, rec.Body)
}
@@ -220,13 +261,13 @@ func TestBrowseHighlightAndMarkdown(t *testing.T) {
}
// A .md blob renders markdown in place of highlighted source.
- rec = x.do(http.MethodGet, "/browse/alice/app/-/blob/README.md?ref=main", nil, session)
+ rec = x.do(http.MethodGet, "/browse/app/-/blob/README.md?ref=main", nil, session)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "<h1") {
t.Fatalf("md blob not rendered:\n%s", rec.Body)
}
// The tree page renders the directory's README below the listing.
- rec = x.do(http.MethodGet, "/browse/alice/app/-/tree/", nil, session)
+ rec = x.do(http.MethodGet, "/browse/app/-/tree/", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("tree = %d", rec.Code)
}
@@ -237,14 +278,14 @@ func TestBrowseHighlightAndMarkdown(t *testing.T) {
func TestBrowseMermaid(t *testing.T) {
x := newHarness(t)
- x.seedBrowseRepo("alice/app", "main")
+ x.seedBrowseRepo("app", "main")
session := x.login(x.mintTokenFor(x.admin.ID))
const script = "/ui/static/mermaid.min.js"
// A markdown blob with a mermaid fence renders the diagram container AND pulls
// in the mermaid script.
- rec := x.do(http.MethodGet, "/browse/alice/app/-/blob/diagram.md?ref=main", nil, session)
+ rec := x.do(http.MethodGet, "/browse/app/-/blob/diagram.md?ref=main", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("diagram blob = %d (%s)", rec.Code, rec.Body)
}
@@ -256,7 +297,7 @@ func TestBrowseMermaid(t *testing.T) {
}
// A markdown blob without a diagram must NOT pull in the script (conditional).
- rec = x.do(http.MethodGet, "/browse/alice/app/-/blob/README.md?ref=main", nil, session)
+ rec = x.do(http.MethodGet, "/browse/app/-/blob/README.md?ref=main", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("readme blob = %d", rec.Code)
}
@@ -279,11 +320,11 @@ func TestBrowseMermaid(t *testing.T) {
func TestBrowseBlobSizeGuard(t *testing.T) {
x := newHarness(t)
- x.seedBrowseRepo("alice/app", "main")
+ x.seedBrowseRepo("app", "main")
session := x.login(x.mintTokenFor(x.admin.ID))
// A blob over the cap falls back to a plain <pre>, no chroma markup.
- rec := x.do(http.MethodGet, "/browse/alice/app/-/blob/big.go?ref=main", nil, session)
+ rec := x.do(http.MethodGet, "/browse/app/-/blob/big.go?ref=main", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("big blob = %d", rec.Code)
}
@@ -297,18 +338,18 @@ func TestBrowseBlobSizeGuard(t *testing.T) {
func TestBrowseNotFoundAndTraversal(t *testing.T) {
x := newHarness(t)
- x.seedBrowseRepo("alice/app", "main")
+ x.seedBrowseRepo("app", "main")
session := x.login(x.mintTokenFor(x.admin.ID))
cases := []struct {
name, target string
}{
- {"unknown repo", "/browse/bob/ghost/-/tree/?ref=main"},
- {"unknown ref", "/browse/alice/app/-/tree/?ref=nope"},
+ {"unknown repo", "/browse/ghost/-/tree/?ref=main"},
+ {"unknown ref", "/browse/app/-/tree/?ref=nope"},
// The path arrives as an uncleaned query param, so it reaches the
// reader's safePath guard rather than being normalized by the mux.
- {"path traversal", "/browse/alice/app/-/commits?ref=main&path=../etc/passwd"},
- {"missing blob", "/browse/alice/app/-/blob/nope.txt?ref=main"},
+ {"path traversal", "/browse/app/-/commits?ref=main&path=../etc/passwd"},
+ {"missing blob", "/browse/app/-/blob/nope.txt?ref=main"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
diff --git a/internal/webui/ci_test.go b/internal/webui/ci_test.go
index 3a8a412..12f74b1 100644
--- a/internal/webui/ci_test.go
+++ b/internal/webui/ci_test.go
@@ -22,6 +22,11 @@ func (x *harness) seedRun(repoName, sha, log string) (repoID, runID, jobID int64
x.t.Fatalf("CreateRepo: %v", err)
}
}
+ // Browse (the CI run views) is gated on repo-read; grant the harness admin an
+ // ACL so it can view runs on this private-by-default repo.
+ if err := x.md.SetACL(ctx, r.ID, x.admin.ID, meta.PermAdmin); err != nil {
+ x.t.Fatalf("SetACL: %v", err)
+ }
run, err := x.md.CreateRun(ctx, r.ID, "refs/heads/main", sha)
if err != nil {
x.t.Fatalf("CreateRun: %v", err)
@@ -43,20 +48,20 @@ func (x *harness) seedRun(repoName, sha, log string) (repoID, runID, jobID int64
func TestCIRunsListAndDetail(t *testing.T) {
x := newHarness(t)
session := x.login(x.mintTokenFor(x.admin.ID))
- _, runID, jobID := x.seedRun("atmin/app", "deadbeefcafe1234", "ok\n")
+ _, runID, jobID := x.seedRun("app", "deadbeefcafe1234", "ok\n")
// Runs list links to the run and shows the short SHA.
- rec := x.do(http.MethodGet, "/browse/atmin/app/-/runs", nil, session)
+ rec := x.do(http.MethodGet, "/browse/app/-/runs", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("runs list: %d (%s)", rec.Code, rec.Body)
}
body := rec.Body.String()
- if !strings.Contains(body, "/browse/atmin/app/-/run/"+itoa(runID)) || !strings.Contains(body, "deadbeef") {
+ if !strings.Contains(body, "/browse/app/-/run/"+itoa(runID)) || !strings.Contains(body, "deadbeef") {
t.Errorf("runs list missing run link or short sha: %s", body)
}
// Run detail shows the job and links to its log.
- rec = x.do(http.MethodGet, "/browse/atmin/app/-/run/"+itoa(runID), nil, session)
+ rec = x.do(http.MethodGet, "/browse/app/-/run/"+itoa(runID), nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("run detail: %d (%s)", rec.Code, rec.Body)
}
@@ -70,9 +75,9 @@ func TestCIJobLogRendersSafely(t *testing.T) {
x := newHarness(t)
session := x.login(x.mintTokenFor(x.admin.ID))
// Green "ok" via ANSI, then HTML that must be escaped.
- _, runID, jobID := x.seedRun("atmin/app", "sha1", "\x1b[32mok\x1b[0m <b>x</b>\n")
+ _, runID, jobID := x.seedRun("app", "sha1", "\x1b[32mok\x1b[0m <b>x</b>\n")
- rec := x.do(http.MethodGet, "/browse/atmin/app/-/run/"+itoa(runID)+"/job/"+itoa(jobID)+"/log", nil, session)
+ rec := x.do(http.MethodGet, "/browse/app/-/run/"+itoa(runID)+"/job/"+itoa(jobID)+"/log", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("log view: %d (%s)", rec.Code, rec.Body)
}
@@ -91,11 +96,15 @@ func TestCIJobLogRendersSafely(t *testing.T) {
func TestCIRunNotFound(t *testing.T) {
x := newHarness(t)
session := x.login(x.mintTokenFor(x.admin.ID))
- if _, err := x.md.CreateRepo(context.Background(), "atmin/app", "main"); err != nil {
+ r, err := x.md.CreateRepo(context.Background(), "app", "main")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := x.md.SetACL(context.Background(), r.ID, x.admin.ID, meta.PermAdmin); err != nil {
t.Fatal(err)
}
// Unknown run id → 404.
- if rec := x.do(http.MethodGet, "/browse/atmin/app/-/run/9999", nil, session); rec.Code != http.StatusNotFound {
+ if rec := x.do(http.MethodGet, "/browse/app/-/run/9999", nil, session); rec.Code != http.StatusNotFound {
t.Errorf("unknown run = %d, want 404", rec.Code)
}
}
@@ -103,12 +112,16 @@ func TestCIRunNotFound(t *testing.T) {
func TestCIRunCrossRepoIsNotFound(t *testing.T) {
x := newHarness(t)
session := x.login(x.mintTokenFor(x.admin.ID))
- _, runID, _ := x.seedRun("atmin/app", "sha1", "x")
- if _, err := x.md.CreateRepo(context.Background(), "atmin/other", "main"); err != nil {
+ _, runID, _ := x.seedRun("app", "sha1", "x")
+ other, err := x.md.CreateRepo(context.Background(), "other", "main")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := x.md.SetACL(context.Background(), other.ID, x.admin.ID, meta.PermAdmin); err != nil {
t.Fatal(err)
}
- // A run that belongs to atmin/app must not be reachable under atmin/other.
- rec := x.do(http.MethodGet, "/browse/atmin/other/-/run/"+itoa(runID), nil, session)
+ // A run that belongs to app must not be reachable under other.
+ rec := x.do(http.MethodGet, "/browse/other/-/run/"+itoa(runID), nil, session)
if rec.Code != http.StatusNotFound {
t.Errorf("cross-repo run = %d, want 404", rec.Code)
}
@@ -117,7 +130,7 @@ func TestCIRunCrossRepoIsNotFound(t *testing.T) {
func TestCIRunsRequireAdmin(t *testing.T) {
x := newHarness(t)
// Unauthenticated GET is redirected to login like the rest of /browse.
- if rec := x.do(http.MethodGet, "/browse/atmin/app/-/runs", nil, nil); rec.Code != http.StatusSeeOther {
+ if rec := x.do(http.MethodGet, "/browse/app/-/runs", nil, nil); rec.Code != http.StatusSeeOther {
t.Errorf("unauth runs = %d, want 303 redirect", rec.Code)
}
}
@@ -126,24 +139,24 @@ func TestCICommitBadge(t *testing.T) {
x := newHarness(t)
ctx := context.Background()
session := x.login(x.mintTokenFor(x.admin.ID))
- head, first := x.seedBrowseRepo("atmin/repo", "main")
- r, _ := x.md.GetRepo(ctx, "atmin/repo")
+ head, first := x.seedBrowseRepo("repo", "main")
+ r, _ := x.md.GetRepo(ctx, "repo")
run, err := x.md.CreateRun(ctx, r.ID, "refs/heads/main", head)
if err != nil {
t.Fatal(err)
}
// The commit with a run shows a badge linking to it.
- rec := x.do(http.MethodGet, "/browse/atmin/repo/-/commit/"+head, nil, session)
+ rec := x.do(http.MethodGet, "/browse/repo/-/commit/"+head, nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("commit page: %d (%s)", rec.Code, rec.Body)
}
- if !strings.Contains(rec.Body.String(), "/browse/atmin/repo/-/run/"+itoa(run.ID)) {
+ if !strings.Contains(rec.Body.String(), "/browse/repo/-/run/"+itoa(run.ID)) {
t.Errorf("commit with a run is missing the CI badge: %s", rec.Body)
}
// A commit without a run shows no badge.
- rec = x.do(http.MethodGet, "/browse/atmin/repo/-/commit/"+first, nil, session)
+ rec = x.do(http.MethodGet, "/browse/repo/-/commit/"+first, nil, session)
if strings.Contains(rec.Body.String(), "/-/run/") {
t.Errorf("commit without a run should have no badge: %s", rec.Body)
}
diff --git a/internal/webui/secrets_test.go b/internal/webui/secrets_test.go
index 5b91478..dc732dd 100644
--- a/internal/webui/secrets_test.go
+++ b/internal/webui/secrets_test.go
@@ -13,7 +13,7 @@ func TestSecretsGoldenPath(t *testing.T) {
ctx := context.Background()
session := x.login(x.mintTokenFor(x.admin.ID))
- repo, err := x.md.CreateRepo(ctx, "atmin/app", "main")
+ repo, err := x.md.CreateRepo(ctx, "app", "main")
if err != nil {
t.Fatalf("CreateRepo: %v", err)
}
@@ -21,7 +21,7 @@ func TestSecretsGoldenPath(t *testing.T) {
// Set a secret. The value must never appear in the response.
const value = "hunter2-NEEDLE"
rec := x.do(http.MethodPost, "/ui/secrets",
- url.Values{"repo": {"atmin/app"}, "name": {"API_TOKEN"}, "value": {value}}, session)
+ url.Values{"repo": {"app"}, "name": {"API_TOKEN"}, "value": {value}}, session)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "saved secret API_TOKEN") {
t.Fatalf("set secret: %d (%s)", rec.Code, rec.Body)
}
@@ -39,7 +39,7 @@ func TestSecretsGoldenPath(t *testing.T) {
}
// The list page shows the name but never the value.
- rec = x.do(http.MethodGet, "/ui/secrets?repo=atmin/app", nil, session)
+ rec = x.do(http.MethodGet, "/ui/secrets?repo=app", nil, session)
if !strings.Contains(rec.Body.String(), "API_TOKEN") {
t.Errorf("list page missing the secret name (body: %s)", rec.Body)
}
@@ -49,7 +49,7 @@ func TestSecretsGoldenPath(t *testing.T) {
// Delete it.
rec = x.do(http.MethodPost, "/ui/secrets/delete",
- url.Values{"repo": {"atmin/app"}, "name": {"API_TOKEN"}}, session)
+ url.Values{"repo": {"app"}, "name": {"API_TOKEN"}}, session)
if rec.Code != http.StatusOK {
t.Fatalf("delete secret: %d (%s)", rec.Code, rec.Body)
}
@@ -62,12 +62,12 @@ func TestSecretsRejectsReservedName(t *testing.T) {
x := newHarness(t)
ctx := context.Background()
session := x.login(x.mintTokenFor(x.admin.ID))
- if _, err := x.md.CreateRepo(ctx, "atmin/app", "main"); err != nil {
+ if _, err := x.md.CreateRepo(ctx, "app", "main"); err != nil {
t.Fatalf("CreateRepo: %v", err)
}
rec := x.do(http.MethodPost, "/ui/secrets",
- url.Values{"repo": {"atmin/app"}, "name": {"WORKER_SECRET"}, "value": {"x"}}, session)
+ url.Values{"repo": {"app"}, "name": {"WORKER_SECRET"}, "value": {"x"}}, session)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "reserved") {
t.Errorf("reserved name: %d (%s), want a rejection message", rec.Code, rec.Body)
}
@@ -82,7 +82,7 @@ func TestSecretsRequireAdmin(t *testing.T) {
t.Errorf("unauth GET /ui/secrets = %d, want 303 redirect", rec.Code)
}
rec := x.do(http.MethodPost, "/ui/secrets",
- url.Values{"repo": {"atmin/app"}, "name": {"K"}, "value": {"v"}}, nil)
+ url.Values{"repo": {"app"}, "name": {"K"}, "value": {"v"}}, nil)
if rec.Code != http.StatusUnauthorized {
t.Errorf("unauth POST /ui/secrets = %d, want 401", rec.Code)
}
diff --git a/internal/webui/webui.go b/internal/webui/webui.go
index fb039e8..05c2f7b 100644
--- a/internal/webui/webui.go
+++ b/internal/webui/webui.go
@@ -6,9 +6,11 @@
// s3lite tables and object store the git path uses; it introduces no new source
// of truth.
//
-// Access is gated on the global-admin role (users.is_admin). A browser logs in
-// by pasting a personal access token once (verified through the same path git
-// auth uses); the server then issues a signed, stateless session cookie.
+// Management (create/list repos, tokens, users, ACLs, secrets) is gated on the
+// global-admin role (users.is_admin); read-only browsing is gated on repo-read
+// (public → anyone, private → an ACL — see authorizeRead). A browser logs in by
+// pasting a personal access token once (verified through the same path git auth
+// uses); the server then issues a signed, stateless session cookie.
package webui
import (
@@ -55,12 +57,6 @@ type SecretsAdmin interface {
DeleteSecret(ctx context.Context, repoID int64, name string) error
}
-// reservedOwners are top-level path segments the server already routes; a repo
-// whose owner is one of these would shadow those routes, so creation refuses it.
-var reservedOwners = map[string]bool{
- "ui": true, "login": true, "logout": true, "healthz": true, "version": true,
-}
-
// nameSegment matches a valid owner or repo path segment.
var nameSegment = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
@@ -167,10 +163,12 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("POST /ui/secrets", h.requireAdmin(h.postSecret))
mux.HandleFunc("POST /ui/secrets/delete", h.requireAdmin(h.postDeleteSecret))
- // Read-only browsing. The repo name contains slashes and the action tail is
- // split manually on "/-/" (see browse), so one subtree handler covers all
- // of tree/blob/raw/commits/commit.
- mux.HandleFunc("GET /browse/{rest...}", h.requireAdmin(h.browse))
+ // Read-only browsing. Gated on repo-read (public → anyone, private → an ACL),
+ // not on admin — this is what makes spectators and public repos usable; the
+ // per-request check lives in browse via authorizeRead. The action tail is
+ // split manually on "/-/" (see browse), so one subtree handler covers all of
+ // tree/blob/raw/commits/commit.
+ mux.HandleFunc("GET /browse/{rest...}", h.browse)
// The vendored mermaid library for diagram rendering, served same-origin.
// Public and non-sensitive (a pinned copy of an open-source library), so it
@@ -291,10 +289,8 @@ func (h *Handler) postRepo(w http.ResponseWriter, r *http.Request) {
h.renderRepos(w, r, "", "owner and name must be alphanumeric (._- allowed, not leading)")
return
}
- if reservedOwners[owner] {
- h.renderRepos(w, r, "", "owner is reserved: "+owner)
- return
- }
+ // The reserved-name and structural rules live in meta.CreateRepo (one source
+ // for routing + validation); its error is surfaced below.
ownerUser, err := h.md.GetUser(r.Context(), owner)
if errors.Is(err, meta.ErrNotFound) {
h.renderRepos(w, r, "", "no such user for owner: "+owner)
@@ -304,8 +300,9 @@ func (h *Handler) postRepo(w http.ResponseWriter, r *http.Request) {
return
}
- full := owner + "/" + name
- repo, err := h.md.CreateRepo(r.Context(), full, branch)
+ // Flat namespace: the repo is a single path segment (no owner/ prefix).
+ // CreateRepo enforces the reserved-name and structural rules.
+ repo, err := h.md.CreateRepo(r.Context(), name, branch)
if err != nil {
h.renderRepos(w, r, "", "create repo: "+err.Error())
return
@@ -316,7 +313,7 @@ func (h *Handler) postRepo(w http.ResponseWriter, r *http.Request) {
h.serverError(w, "grant owner acl", err)
return
}
- h.renderRepos(w, r, "created "+full, "")
+ h.renderRepos(w, r, "created "+name, "")
}
func (h *Handler) postDefaultBranch(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/webui/webui_test.go b/internal/webui/webui_test.go
index b01cb76..bd80908 100644
--- a/internal/webui/webui_test.go
+++ b/internal/webui/webui_test.go
@@ -141,21 +141,21 @@ func TestGoldenPath(t *testing.T) {
// Create a repo owned by alice.
rec := x.do(http.MethodPost, "/ui/repos",
url.Values{"owner": {"alice"}, "name": {"app"}, "default_branch": {"main"}}, session)
- if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "created alice/app") {
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "created app") {
t.Fatalf("create repo: %d (%s)", rec.Code, rec.Body)
}
- repo, err := x.md.GetRepo(ctx, "alice/app")
+ repo, err := x.md.GetRepo(ctx, "app")
if err != nil {
t.Fatalf("repo not created: %v", err)
}
// Set default branch.
rec = x.do(http.MethodPost, "/ui/repos/default-branch",
- url.Values{"repo": {"alice/app"}, "default_branch": {"trunk"}}, session)
+ url.Values{"repo": {"app"}, "default_branch": {"trunk"}}, session)
if rec.Code != http.StatusOK {
t.Fatalf("set default branch: %d (%s)", rec.Code, rec.Body)
}
- if got, _ := x.md.GetRepo(ctx, "alice/app"); got.DefaultBranch != "trunk" {
+ if got, _ := x.md.GetRepo(ctx, "app"); got.DefaultBranch != "trunk" {
t.Errorf("default branch = %q, want trunk", got.DefaultBranch)
}
@@ -172,7 +172,7 @@ func TestGoldenPath(t *testing.T) {
// Grant write to alice on her repo.
rec = x.do(http.MethodPost, "/ui/acls",
- url.Values{"repo": {"alice/app"}, "handle": {"alice"}, "perm": {"write"}}, session)
+ url.Values{"repo": {"app"}, "handle": {"alice"}, "perm": {"write"}}, session)
if rec.Code != http.StatusOK {
t.Fatalf("grant acl: %d (%s)", rec.Code, rec.Body)
}
@@ -182,7 +182,7 @@ func TestGoldenPath(t *testing.T) {
// Revoke it again.
rec = x.do(http.MethodPost, "/ui/acls/revoke",
- url.Values{"repo": {"alice/app"}, "user_id": {itoa(alice.ID)}}, session)
+ url.Values{"repo": {"app"}, "user_id": {itoa(alice.ID)}}, session)
if rec.Code != http.StatusOK {
t.Fatalf("revoke acl: %d (%s)", rec.Code, rec.Body)
}
@@ -208,7 +208,7 @@ func TestCreateRepoGrantsOwnerAdmin(t *testing.T) {
}
bob, _ := x.md.GetUser(ctx, "bob")
- repo, err := x.md.GetRepo(ctx, "bob/proj")
+ repo, err := x.md.GetRepo(ctx, "proj")
if err != nil {
t.Fatalf("repo not created: %v", err)
}
@@ -257,10 +257,13 @@ func TestCreateRepoRejectsReservedAndUnknownOwner(t *testing.T) {
x := newHarness(t)
session := x.login(x.mintTokenFor(x.admin.ID))
- // Reserved top-level owner would shadow a route.
- rec := x.do(http.MethodPost, "/ui/repos", url.Values{"owner": {"ui"}, "name": {"x"}}, session)
+ // A reserved repo name (a global route) is refused by meta.CreateRepo.
+ rec := x.do(http.MethodPost, "/ui/repos", url.Values{"owner": {"root"}, "name": {"login"}}, session)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "reserved") {
- t.Errorf("reserved owner: %d (%s)", rec.Code, rec.Body)
+ t.Errorf("reserved name: %d (%s)", rec.Code, rec.Body)
+ }
+ if _, err := x.md.GetRepo(context.Background(), "login"); err == nil {
+ t.Error("repo created despite a reserved name")
}
// Owner must be an existing user.
@@ -268,7 +271,7 @@ func TestCreateRepoRejectsReservedAndUnknownOwner(t *testing.T) {
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "no such user") {
t.Errorf("unknown owner: %d (%s)", rec.Code, rec.Body)
}
- if _, err := x.md.GetRepo(context.Background(), "ghost/x"); err == nil {
+ if _, err := x.md.GetRepo(context.Background(), "x"); err == nil {
t.Error("repo created despite unknown owner")
}
}
diff --git a/scripts/dev.sh b/scripts/dev.sh
index 8069481..86d22d1 100755
--- a/scripts/dev.sh
+++ b/scripts/dev.sh
@@ -58,7 +58,7 @@ echo "--- starting MinIO (S3 :9100, console :9101) ---"
# server also auto-bootstraps on a truly empty bucket — this just makes the token
# and the dev repo available up front). 'make dev-reset' wipes the DB to start over.
echo "--- ensuring admin + dev repo (idempotent) ---"
-bin/gitmote bootstrap -handle atmin -repo atmin/gitmote -default-branch master >/dev/null
+bin/gitmote bootstrap -handle atmin -repo gitmote -default-branch master >/dev/null
TOKEN="$(bin/gitmote bootstrap -reissue -handle atmin | grep -oE 'gmt_[0-9a-f]+\.[0-9a-f]+' | head -1)"
[ -n "$TOKEN" ] || { echo "ERROR: no token from 'bootstrap -reissue'" >&2; exit 1; }
@@ -68,8 +68,8 @@ cat <<EOF
UI: http://localhost:8080/ui (sign in at /login by pasting the token)
token: $TOKEN
- clone: git clone http://atmin:$TOKEN@localhost:8080/atmin/gitmote
- push: git push http://atmin:$TOKEN@localhost:8080/atmin/gitmote HEAD:refs/heads/master
+ clone: git clone http://atmin:$TOKEN@localhost:8080/gitmote
+ push: git push http://atmin:$TOKEN@localhost:8080/gitmote HEAD:refs/heads/master
MinIO console: http://localhost:9101 (minioadmin / minioadmin)
Ctrl-C stops the server; MinIO keeps running. 'make dev-reset' wipes all state.
diff --git a/scripts/e2e-local.sh b/scripts/e2e-local.sh
index 11592b2..5de6252 100755
--- a/scripts/e2e-local.sh
+++ b/scripts/e2e-local.sh
@@ -68,11 +68,11 @@ $DC up -d minio
$DC run --rm mc
echo "--- bootstrap (one-shot, writes admin/token/repo to the metadata volume) ---"
-BOOT="$($DC run --rm -T gitmote bootstrap -handle atmin -repo atmin/gitmote -default-branch "$BRANCH")"
+BOOT="$($DC run --rm -T gitmote bootstrap -handle atmin -repo gitmote -default-branch "$BRANCH")"
TOKEN="$(printf '%s\n' "$BOOT" | grep -oE 'gmt_[0-9a-f]+\.[0-9a-f]+' | head -1)"
[ -n "$TOKEN" ] || { printf '%s\n' "$BOOT"; fail "no token in bootstrap output"; }
-REPO_URL="http://atmin:${TOKEN}@localhost:8080/atmin/gitmote"
+REPO_URL="http://atmin:${TOKEN}@localhost:8080/gitmote"
echo "--- starting gitmote server ---"
$DC up -d gitmote
diff --git a/scripts/e2e-restore.sh b/scripts/e2e-restore.sh
index a2defe5..9ab0b1b 100755
--- a/scripts/e2e-restore.sh
+++ b/scripts/e2e-restore.sh
@@ -47,10 +47,10 @@ $DC up -d minio
$DC run --rm mc
echo "--- bootstrap (replicates initial metadata to S3) ---"
-BOOT="$($DC run --rm -T gitmote bootstrap -handle atmin -repo atmin/gitmote -default-branch "$BRANCH")"
+BOOT="$($DC run --rm -T gitmote bootstrap -handle atmin -repo gitmote -default-branch "$BRANCH")"
TOKEN="$(printf '%s\n' "$BOOT" | grep -oE 'gmt_[0-9a-f]+\.[0-9a-f]+' | head -1)"
[ -n "$TOKEN" ] || { printf '%s\n' "$BOOT"; fail "no token in bootstrap output"; }
-REPO_URL="http://atmin:${TOKEN}@localhost:8080/atmin/gitmote"
+REPO_URL="http://atmin:${TOKEN}@localhost:8080/gitmote"
echo "--- start server + push ($BRANCH @ $PUSHED) ---"
$DC up -d gitmote
diff --git a/tasks/README.md b/tasks/README.md
index 0be8263..d317d33 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -18,11 +18,10 @@ namespace** (`host/<repo>`, no `owner/`, no `/-/`), content addressed in-path
visibility with spectators & collaborators, and rendered-markdown links that
actually resolve. Greenfield — **reset the bucket + meta**, no migration. Design +
rationale: [../docs/architecture/urls.md](../docs/architecture/urls.md). Order
-within the chain matters:
+within the chain matters — the **flat namespace + visibility & access** slice has
+landed (single-segment repos, `visibility`, repo-read browse authz, admin-only
+default-branch force-push); the rest remain:
-- [urls-namespace-access.md](urls-namespace-access.md) — flat single-segment repos
- + `visibility` (public → anonymous read; write always needs an ACL) + repo-read
- browse authz + **admin-only force-push of the default branch**.
- [urls-content-routing.md](urls-content-routing.md) — `tree`/`blob`/`raw` verbs,
ref-in-path greedy resolution, default-branch landing. *(needs -namespace.)*
- [urls-markdown-links.md](urls-markdown-links.md) — rewrite rendered-markdown
diff --git a/tasks/urls-namespace-access.md b/tasks/urls-namespace-access.md
deleted file mode 100644
index b4bc6a2..0000000
--- a/tasks/urls-namespace-access.md
+++ /dev/null
@@ -1,102 +0,0 @@
-# Flat repo namespace + visibility & access
-
-Part of the **URLs** epic ([README](README.md)); implements the namespace and
-access sections of [../docs/architecture/urls.md](../docs/architecture/urls.md).
-First in the chain — everything else routes off single-segment repos.
-
-## Spec
-
-- Repos are a **single path segment** (`gitmote`, not `atmin/gitmote`). Reachable
- at `/<repo>` for git. Reserved names (enforced at create): the top-level globals
- `login`, `logout`, `users`, `tokens`, `settings`, `new`, `search`, `api`,
- `metrics`, `internal`, `static`, `healthz`, `version`; plus two structural
- rules — a name may not start with `.` or be the bare `-` (nor be empty). A
- trailing `.git` is stripped. (`internal` is already the CI report-API route;
- the rest are reserved ahead of need — the `.`/`-` rules keep future system
- routes possible without a breaking rename.)
-- **Visibility** per repo: `private` (default) or `public`. A public repo is
- readable with **no token** (clone/fetch/browse); writes and management are
- **never** anonymous.
-- The ACL perms already exist — **`read` = spectator, `write` = collaborator,
- `admin`**. Reading is gated by **repo-read** (public → anyone, private → a
- `read` ACL), not by global admin.
-- **Force-pushing the default branch is admin-only.** A `write` collaborator may
- fast-forward any branch and force-push/delete *non-default* branches, but a
- **non-fast-forward or deletion of the default branch requires `admin`**.
-- Greenfield: no users yet, so **reset** rather than migrate.
-
-## Current
-
-`repos.name` is `owner/repo`, `UNIQUE` with an embedded slash
-([`schema.sql`](../internal/meta/schema.sql), [`repos.go`](../internal/meta/repos.go)).
-The git handler and writer route on `/<owner>/<repo>/…`
-([`internal/githttp`](../internal/githttp), [`cmd/gitmote/main.go`](../cmd/gitmote/main.go)).
-[`auth.Guard.Authorize`](../internal/auth/guard.go) requires a valid token + a
-matching ACL for every request; there is no anonymous path and no `visibility`
-column. Browse is **admin-only** ([`webui.go`](../internal/webui/webui.go),
-`h.requireAdmin(h.browse)`). Bootstrap and the dev/e2e scripts use `atmin/gitmote`.
-
-## Change
-
-- **Reset (greenfield).** Wipe the bucket + meta and re-bootstrap a single repo
- `gitmote`; no migration path is written. Locally that's `make dev-reset`; in prod
- it's dropping the bucket's `objects/` and `meta` prefixes, then re-bootstrapping
- ([ops.md](../docs/ops.md)). Because the DB is fresh, the schema change is
- `schema.sql` only — no guarded `migrate()` entry needed.
-- **Schema** ([`schema.sql`](../internal/meta/schema.sql)): `repos.name` stays
- `UNIQUE` but now holds a single segment; add
- `visibility TEXT NOT NULL DEFAULT 'private' CHECK (visibility IN ('private','public'))`.
-- **Repo-name validation** in `CreateRepo` ([`repos.go`](../internal/meta/repos.go)):
- reject a `/`, any reserved global (see Spec), a leading `.`, the bare `-`, and
- empty; strip a trailing `.git`. Keep the reserved set in one place (a package
- var) so routes and validation can't drift. Add `GetRepo`-side `.git` tolerance
- so `clone host/<repo>.git` resolves.
-- **Routing**: parse `/<repo>/…` (drop the owner segment) in
- [`internal/githttp`](../internal/githttp) and the mux wiring in
- [`main.go`](../cmd/gitmote/main.go). The git handler keeps the `/` catch-all;
- the UI verb routes ([urls-content-routing.md](urls-content-routing.md)) share
- that space, so **only enumerated verbs** may be registered — never a broad
- `/{repo}/{rest...}` — or git's `info/refs` / `*-pack` endpoints get swallowed
- (see the mux seam in [../docs/architecture/urls.md](../docs/architecture/urls.md)).
-- **Authz** ([`guard.go`](../internal/auth/guard.go)): when `perm == read` and the
- repo is `public`, allow with no token; otherwise the existing token+ACL path.
- `write`/`admin` are unchanged (never anonymous). Add a `visibility`-aware repo
- read the UI and git-read paths share.
-- **Default-branch force-push guard** in the **pre-receive hook** path (per-ref, so
- the client gets a clean rejection — not a post-hoc CAS failure;
- [`internal/githttp`](../internal/githttp), [`internal/hookrpc`](../internal/hookrpc)).
- A non-fast-forward or deletion of `repos.default_branch` is rejected unless the
- pusher holds `admin`; fast-forwards and all pushes to other branches need only
- `write`. Three inputs must reach that point: the pusher's **ACL level** (admin vs
- write — `classify` today only gates ≥`write`, so admin must be resolved
- separately, e.g. `GetACL`/`is_admin`), the repo's `default_branch`, and per-ref
- **non-ff / delete** detection (old→new ancestry; delete = all-zeros new SHA). The
- rejection is a real refusal, not a silent drop.
-- **Browse authz** ([`webui.go`](../internal/webui/webui.go)): gate browse/read on
- repo-read (public → anyone, private → `read` ACL), replacing `requireAdmin` on
- the read routes; management routes stay admin.
-- **Bootstrap** ([`internal/bootstrap`](../internal/bootstrap),
- [`main.go`](../cmd/gitmote/main.go)): default/require a single-segment repo name;
- update the dev/e2e scripts and README/ops examples to `gitmote`.
-- **Docs**: reconcile [storage.md](../docs/architecture/storage.md) (repos.name
- example), [auth.md](../docs/architecture/auth.md) (visibility + anonymous read),
- and drop the `owner/repo` phrasing from [urls.md](../docs/architecture/urls.md)'s
- Status once landed.
-
-## Verify
-
-- `git clone/push http://<host>/gitmote` works (no owner segment).
-- **Public repo:** anonymous `clone`/`fetch` and browse succeed; anonymous push is
- rejected; a `write`-ACL user can push.
-- **Private repo:** anonymous read is refused; a `read`-ACL user (spectator) can
- clone/browse but not push; a `write`-ACL user (collaborator) can push.
-- **Force-push guard:** a `write` collaborator's fast-forward to the default
- branch succeeds; their **non-fast-forward** to it is **refused**; the same
- force-push to a non-default branch succeeds; an `admin` can force-push the
- default branch. (Golden + failure both tested — a force that isn't refused is a
- bug, per CONTRIBUTING.)
-- Creating a repo named `login`/`api`/`internal` (any reserved global), one
- starting with `.`, the bare `-`, containing `/`, or empty is rejected with a
- clear error; `gitmote.git` normalizes to `gitmote`.
-- `gofmt`/`golangci-lint`/`go test ./...` clean; golden **and** failure paths
- (anonymous-write refusal, private-read refusal, reserved-name rejection).