Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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/templates.go +174−2

@@ -6,6 +6,7 @@ const templates = `
6 6 <head>
7 7 <meta charset="utf-8">
8 8 <meta name="viewport" content="width=device-width,initial-scale=1">
9 <meta name="csrf" content="{{.CSRF}}">
9 10 <title>{{.Title}} · Vole</title>
10 11 <style>
11 12 :root{
@@ -29,7 +30,18 @@ th{color:var(--muted);font-weight:500;font-size:.82rem;text-transform:uppercase;
29 30 code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
30 31 code{background:var(--card);border:1px solid var(--line);border-radius:4px;padding:.1rem .35rem;font-size:.88em}
31 32 pre{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:1rem;overflow:auto;font-size:.86rem;line-height:1.5}
32 select{background:var(--card);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:.35rem .55rem;font:inherit;font-size:.9rem}
33 select,input[type=text],input[type=password],textarea{background:var(--card);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:.45rem .55rem;font:inherit;font-size:.9rem}
34 input[type=text],input[type=password],textarea{width:100%;max-width:32rem}
35 textarea{min-height:8rem;resize:vertical}
36 label{display:block;margin:.6rem 0;color:var(--muted);font-size:.9rem}
37 button{background:var(--accent);color:#08101d;border:0;border-radius:6px;padding:.45rem .8rem;font:inherit;font-weight:600;cursor:pointer}
38 button:disabled{opacity:.5;cursor:default}
39 button.link{border:0;background:none;color:var(--accent);padding:0;font:inherit;font-weight:400;cursor:pointer}
40 button.link:hover{text-decoration:underline}
41 form.inline{display:inline}
42 .auth{max-width:36rem}
43 .error{color:var(--bad)}
44 .bad{color:var(--bad)}
33 45 .card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:1rem;margin:1rem 0}
34 46 .row{display:flex;gap:1.5rem;flex-wrap:wrap}
35 47 .row>div{flex:1 1 18rem}
@@ -66,10 +78,16 @@ select{background:var(--card);color:var(--fg);border:1px solid var(--line);borde
66 78 <body>
67 79 <header>
68 80 <a class="brand" href="/">vole</a>
69 <nav><a href="/runners">runners</a> · read-only · push over ssh</nav>
81 <nav>
82 {{if .LoggedIn}}<span class="muted">{{.User}}</span> · <a href="/account">account</a> ·
83 <form class="inline" method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link" type="submit">log out</button></form>
84 {{else}}<a href="/login">log in</a>{{end}}
85 · <a href="/runners">runners</a> · push over ssh
86 </nav>
70 87 </header>
71 88 <main>
72 89 {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
90 <script src="/auth.js" defer></script>
73 91 {{end}}
74 92
75 93 {{define "footer"}}
@@ -168,6 +186,28 @@ select{background:var(--card);color:var(--fg);border:1px solid var(--line);borde
168 186 <h2>Recent commits</h2>
169 187 {{template "commitlist" .}}
170 188
189 {{if .ContributorBranches}}
190 <h2>Contributor branches</h2>
191 <p class="muted">CI does not run for these branches until a maintainer allows the current commit.</p>
192 <table>
193 <tr><th>Branch</th><th>CI</th></tr>
194 {{range .ContributorBranches}}
195 <tr>
196 <td><code>{{.}}</code></td>
197 <td>
198 {{if $.CanAllowCI}}
199 <form method="post" action="/r/{{$.Name}}/ci/allow">
200 <input type="hidden" name="csrf" value="{{$.CSRF}}">
201 <input type="hidden" name="ref" value="refs/heads/{{.}}">
202 <button type="submit">Allow CI for this tip</button>
203 </form>
204 {{else}}<span class="muted">waiting for a maintainer to allow it</span>{{end}}
205 </td>
206 </tr>
207 {{end}}
208 </table>
209 {{end}}
210
171 211 {{template "repo-ci" .}}
172 212 {{template "footer" .}}
173 213 {{end}}
@@ -281,6 +321,14 @@ select{background:var(--card);color:var(--fg);border:1px solid var(--line);borde
281 321 {{if .Filtered}}<a href="/r/{{.Name}}/issues">clear</a>{{end}}
282 322 </form>
283 323 <p class="muted">{{.Open}} open · {{.Total}} total</p>
324 {{if .CanDiscuss}}
325 <form method="post" action="/r/{{.Name}}/issues" class="card">
326 <input type="hidden" name="csrf" value="{{.CSRF}}">
327 <label>Title <input type="text" name="title" required maxlength="200"></label>
328 <label>Description <textarea name="body" placeholder="Markdown is welcome."></textarea></label>
329 <button type="submit">Open issue</button>
330 </form>
331 {{end}}
284 332 {{if .Labels}}
285 333 <p class="muted">Labels:
286 334 {{range .Labels}}<a class="label" href="/r/{{$.Name}}/issues?label={{.}}">{{.}}</a>{{end}}
@@ -339,6 +387,13 @@ labels, then the body as markdown, which you can write with any editor.</p>
339 387 <p class="muted">No comments yet. From a clone of this repository:
340 388 <code>vole issue comment {{.Issue.ID}} -m "..."</code></p>
341 389 {{end}}
390 {{if .CanDiscuss}}
391 <form method="post" action="/r/{{.Name}}/issues/{{.Issue.ID}}/comments" class="card">
392 <input type="hidden" name="csrf" value="{{.CSRF}}">
393 <label>Comment <textarea name="body" required></textarea></label>
394 <button type="submit">Comment</button>
395 </form>
396 {{end}}
342 397 {{if .Backlinks}}
343 398 <h2>Referenced in commits</h2>
344 399 <table>
@@ -368,6 +423,19 @@ labels, then the body as markdown, which you can write with any editor.</p>
368 423 {{template "header" .}}
369 424 <h1><a href="/r/{{.Name}}">{{.Name}}</a> · change requests</h1>
370 425 <p class="muted">{{.Open}} open · {{.Total}} total</p>
426 {{if .CanOpen}}
427 <form method="post" action="/r/{{.Name}}/requests" class="card">
428 <input type="hidden" name="csrf" value="{{.CSRF}}">
429 <label>Title <input type="text" name="title" required maxlength="200"></label>
430 <div class="row">
431 <div><label>Base <select name="base">{{range .BaseBranches}}<option value="{{.}}"{{if eq . $.Base}} selected{{end}}>{{.}}</option>{{end}}</select></label></div>
432 <div><label>Head <select name="head">{{range .HeadBranches}}<option value="{{.}}">{{.}}</option>{{end}}</select></label></div>
433 </div>
434 <label>Description <textarea name="body" placeholder="Why should this land?"></textarea></label>
435 <button type="submit">Open change request</button>
436 {{if not .HeadBranches}}<p class="muted">Push a branch first. Contributors can push only under <code>refs/heads/contrib/&lt;account-id&gt;/</code>.</p>{{end}}
437 </form>
438 {{end}}
371 439 {{if .Rows}}
372 440 <table>
373 441 <tr><th>ID</th><th>Title</th><th>Base</th><th>Head</th><th>Approvals</th><th>State</th><th>Age</th></tr>
@@ -421,6 +489,13 @@ commit to land, plus <code>approvals/&lt;account-id&gt;.md</code> from each appr
421 489 {{end}}
422 490
423 491 <h2>Approvals</h2>
492 {{if .CanApprove}}
493 <form method="post" action="/r/{{.Name}}/requests/{{.Request.ID}}/approve" class="card">
494 <input type="hidden" name="csrf" value="{{.CSRF}}">
495 <p class="muted">Approving names the current head <span class="sha">{{short .Request.Head}}</span>; a new head starts over.</p>
496 <button type="submit">Approve this head</button>
497 </form>
498 {{end}}
424 499 {{if .Required}}<p class="muted">{{.Current}} of {{.Required}} required approval(s).</p>
425 500 {{else}}<p class="muted">This repository requires no approvals: only an admin may push.</p>
426 501 {{end}}
@@ -584,6 +659,103 @@ release a description; the tarball below works either way.</p>
584 659 {{template "footer" .}}
585 660 {{end}}
586 661
662
663 {{define "login"}}
664 {{template "header" .}}
665 <div class="auth card">
666 <h1>Log in</h1>
667 {{if not .Enabled}}
668 <p class="muted">{{.Reason}}</p>
669 {{else}}
670 <form data-auth="login" data-next="{{.Next}}">
671 <label>Account name
672 <input type="text" name="username" autocomplete="username webauthn" required autofocus>
673 </label>
674 <button type="submit">Use a passkey</button>
675 <p class="error" data-auth-error></p>
676 </form>
677 <p class="muted">No account yet? Ask an administrator for a one-time enrollment link.</p>
678 {{end}}
679 </div>
680 {{template "footer" .}}
681 {{end}}
682
683 {{define "invite"}}
684 {{template "header" .}}
685 <div class="auth card">
686 {{if .Account}}
687 <h1>Join {{.Account.Name}}</h1>
688 {{if not .Enabled}}
689 <p class="muted">{{.Reason}}</p>
690 {{else if .Token}}
691 <p class="muted">Register this device as a passkey for <strong>{{.Account.Name}}</strong>. The link works once.</p>
692 <form data-auth="invite" data-token="{{.Token}}">
693 <button type="submit">Create a passkey</button>
694 <p class="error" data-auth-error></p>
695 </form>
696 {{else}}
697 <p class="error">This enrollment link is no longer valid.</p>
698 {{end}}
699 {{else}}
700 <h1>Enrollment link</h1>
701 <p class="error">This enrollment link is invalid or has expired. Ask an administrator for a new one.</p>
702 {{end}}
703 </div>
704 {{template "footer" .}}
705 {{end}}
706
707 {{define "account"}}
708 {{template "header" .}}
709 <h1>Account</h1>
710 <p><strong>{{.Account.Name}}</strong> <span class="muted">{{.Account.ID}}</span></p>
711
712 <h2>Passkeys</h2>
713 {{if .Credentials}}
714 <table>
715 <tr><th>Credential</th><th>Label</th><th>Created</th><th>Last used</th></tr>
716 {{range .Credentials}}
717 <tr>
718 <td class="sha">{{credid .ID}}</td>
719 <td>{{if .Label}}{{.Label}}{{else}}passkey{{end}}</td>
720 <td class="muted">{{since .Created.Unix}}</td>
721 <td class="muted">{{if .LastUsed.IsZero}}never{{else}}{{since .LastUsed.Unix}}{{end}}</td>
722 </tr>
723 {{end}}
724 </table>
725 {{else}}
726 <p class="muted">No passkeys yet.</p>
727 {{end}}
728
729 {{if .Enabled}}
730 <h2>Add a passkey</h2>
731 <form data-auth="passkey">
732 <label>Label <input type="text" name="label" placeholder="laptop, phone, security key"></label>
733 <button type="submit">Register this device</button>
734 <p class="error" data-auth-error></p>
735 </form>
736 {{else}}
737 <p class="muted">{{.Reason}}</p>
738 {{end}}
739
740 <h2>Sessions</h2>
741 {{if .Sessions}}
742 <table>
743 <tr><th>Created</th><th>Last seen</th><th>Expires</th><th>Client</th></tr>
744 {{range .Sessions}}
745 <tr>
746 <td class="muted">{{since .Created.Unix}}</td>
747 <td class="muted">{{since .LastSeen.Unix}}</td>
748 <td class="muted">{{.Expires.Format "2006-01-02 15:04"}}</td>
749 <td class="muted">{{.UserAgent}}</td>
750 </tr>
751 {{end}}
752 </table>
753 {{else}}
754 <p class="muted">No sessions.</p>
755 {{end}}
756 {{template "footer" .}}
757 {{end}}
758
587 759 {{define "runners"}}
588 760 {{template "header" .}}
589 761 <h1>Runners</h1>