vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Add browser auth, contributor access, and gated CI
6c84d387e5f608360e30484a1a5b1cff41076850 · Keenan Nemetz · 16d ago · parent c36d5073
Add browser auth, contributor access, and gated CI Browser sign-in now uses passkeys. Enrollment links are one-time, sessions are hashed in users.db, and issue/comment/approval writes use the session account. HTTPS public_url is required for WebAuthn. Add the contributor role between read and maintain. Contributors can discuss and open change requests, and may push only refs/heads/contrib/<account-id>/. Contributor CI is held per commit until a maintainer allows the exact tip from the repository page. Rename the write role to maintain. write remains accepted as the historical spelling, and access.Write remains as a Go alias so existing callers and ACL rows keep working. Update the web, CLI, queue schema, and guides for the new flows, and cover the role, namespace, auth, and CI-gating paths in tests.
server/web/auth_test.go +50
| @@ -0,0 +1,50 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "testing" | ||
| 5 | |||
| 6 | "github.com/rodent-software/vole/forge/config" | ||
| 7 | ) | ||
| 8 | |||
| 9 | func TestNewAuthRequiresHTTPS(t *testing.T) { | ||
| 10 | t.Parallel() | ||
| 11 | for _, c := range []struct { | ||
| 12 | url string | ||
| 13 | want bool | ||
| 14 | }{ | ||
| 15 | {"", false}, | ||
| 16 | {"http://forge.example.com", false}, | ||
| 17 | {"https://forge.example.com", true}, | ||
| 18 | {"https://forge.example.com:8443", true}, | ||
| 19 | } { | ||
| 20 | cfg := &config.Config{PublicURL: c.url} | ||
| 21 | a, err := newAuth(cfg) | ||
| 22 | if err != nil { | ||
| 23 | t.Fatalf("%q: %v", c.url, err) | ||
| 24 | } | ||
| 25 | if a.Enabled() != c.want { | ||
| 26 | t.Fatalf("%q: Enabled=%v want %v (%s)", c.url, a.Enabled(), c.want, a.Disabled) | ||
| 27 | } | ||
| 28 | } | ||
| 29 | a, err := newAuth(&config.Config{PublicURL: "https://Forge.Example.com:8443/path"}) | ||
| 30 | if err != nil { | ||
| 31 | t.Fatal(err) | ||
| 32 | } | ||
| 33 | if a.RPID != "forge.example.com" || a.Origin != "https://Forge.Example.com:8443" { | ||
| 34 | t.Fatalf("RPID/Origin = %q/%q", a.RPID, a.Origin) | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | func TestSafeNextOnlyAllowsLocalPaths(t *testing.T) { | ||
| 39 | t.Parallel() | ||
| 40 | for _, in := range []string{"", "//evil.example", "https://evil.example", `/\evil`, "/\r\n"} { | ||
| 41 | if got := safeNext(in); got != "/" { | ||
| 42 | t.Errorf("safeNext(%q)=%q, want /", in, got) | ||
| 43 | } | ||
| 44 | } | ||
| 45 | for _, in := range []string{"/", "/r/demo", "/r/demo?x=1#c-2"} { | ||
| 46 | if got := safeNext(in); got != in { | ||
| 47 | t.Errorf("safeNext(%q)=%q, want itself", in, got) | ||
| 48 | } | ||
| 49 | } | ||
| 50 | } | ||