fix(auth): first-account bootstrap — admin emails activate on signup; add CI

A fresh hub following docs/self-hosting.md was a locked room: invite-only
(the default) showed "Sign up disabled" with nobody to mint an invite, and
the approval-gated posture stranded the first admin as pending forever.

Emails on the config's admin list are operator-vetted, so they now
activate immediately on signup (any posture), and while the hub has zero
accounts they may sign up even on an invite-only hub. Strangers still
can't take the bootstrap slot, and the door closes after the first
account. Validated end to end from scratch: hub boot → admin signup →
device-code login × 2 devices → init → bidirectional sync → hooks install.

Also adds the missing GitHub Actions CI workflow (build/vet/test on
ubuntu + macos) — the repo previously had no CI at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Snow Lee
2026-07-21 23:24:25 -07:00
co-authored by Claude Fable 5
parent 16210f3236
commit 623da892d2
4 changed files with 110 additions and 8 deletions
+21
View File
@@ -0,0 +1,21 @@
name: ci
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
build-test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: go build ./...
- run: go vet ./...
- run: go test ./...
+10 -5
View File
@@ -31,8 +31,9 @@ hub over HTTPS.
"upload": true, "upload": true,
"auth": { "auth": {
// Signup is invite-only by default — the safe posture for a public // Signup is invite-only by default — the safe posture for a public
// URL. Your first account: start once with signup gated (or use an // URL. Bootstrap: while the hub has zero accounts, the emails listed
// org invite), then invite teammates from the web UI. // in "admins" can sign up directly; then invite teammates from the
// web UI.
"admins": ["you@example.com"], "admins": ["you@example.com"],
"users_db": "/var/lib/bdrive/auth.json" "users_db": "/var/lib/bdrive/auth.json"
}, },
@@ -58,8 +59,11 @@ as bearer tokens. For containers, the repo ships a `Dockerfile`
## 5. First sign-in and first project ## 5. First sign-in and first project
1. Open `https://your-hub/`create your account (first run: use the 1. Open `https://your-hub/`sign up with one of the `admins` emails
signup posture you configured; hub admins are the `admins` emails). from your config. On a brand-new hub these can always create the
first account (whatever the signup posture); it activates
immediately, and admin-listed emails skip the approval/verification
gates on any posture.
2. On any machine: `bdrive login https://your-hub` (browser flow), then 2. On any machine: `bdrive login https://your-hub` (browser flow), then
in the folder you want synced: in the folder you want synced:
`bdrive init --name wiki --yes` — or `--shared docs` inside a repo to `bdrive init --name wiki --yes` — or `--shared docs` inside a repo to
@@ -85,7 +89,8 @@ no plaintext credentials ever touch disk).
**Signup is invite-only by default** — the safe posture for a hub on a **Signup is invite-only by default** — the safe posture for a hub on a
public URL. New people get in only through an expiring invite link an owner public URL. New people get in only through an expiring invite link an owner
mints; the link lets them create an account (bypassing the gates below) and mints; the link lets them create an account (bypassing the gates below) and
join, in one step. To allow self-service signup instead, set join, in one step. (One exception so a fresh hub isn't a locked room: while
zero accounts exist, the config's `admins` emails may sign up directly.) To allow self-service signup instead, set
`"allow_signup": true` **with a gate** — the server refuses to start an open `"allow_signup": true` **with a gate** — the server refuses to start an open
hub that has none, so a fake email can never just walk in. Three postures: hub that has none, so a fake email can never just walk in. Three postures:
+57
View File
@@ -142,6 +142,63 @@ func TestSignupDisabled(t *testing.T) {
} }
} }
// A fresh approval-gated hub must not strand its first admin: an email on the
// config's admin list activates on signup instead of waiting for an approver
// who doesn't exist yet.
func TestSignupAdminBypassesApproval(t *testing.T) {
srv, auth, _ := authHub(t, true)
auth.RequireApproval = true
auth.Admins = map[string]bool{"admin@x.io": true}
h := srv.Handler()
cookie := signupAndSession(t, h, "admin@x.io", "Admin", "password1")
if cookie == nil {
t.Fatal("admin signup did not start a session")
}
// A non-admin under the same posture still lands pending.
form := url.Values{"email": {"b@x.io"}, "name": {"B"}, "password": {"password1"}}
req := httptest.NewRequest("POST", "/auth/signup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "waiting for an administrator") {
t.Fatalf("non-admin signup should be pending: %d %s", rec.Code, rec.Body)
}
}
// A brand-new invite-only hub has nobody to mint an invite: until the first
// account exists, config-listed admin emails may sign up directly; everyone
// else stays out, and the door closes again after the first account.
func TestSignupInviteOnlyBootstrap(t *testing.T) {
srv, auth, _ := authHub(t, false)
auth.Admins = map[string]bool{"admin@x.io": true}
h := srv.Handler()
// a stranger can't take the bootstrap slot
form := url.Values{"email": {"b@x.io"}, "name": {"B"}, "password": {"password1"}}
req := httptest.NewRequest("POST", "/auth/signup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "only a hub admin") {
t.Fatalf("stranger during bootstrap: %d %s", rec.Code, rec.Body)
}
// the configured admin signs up and is active immediately
if signupAndSession(t, h, "admin@x.io", "Admin", "password1") == nil {
t.Fatal("admin bootstrap signup did not start a session")
}
// with the first account in place the hub is invite-only again
req = httptest.NewRequest("GET", "/auth/signup", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "invite-only") {
t.Fatalf("signup page after bootstrap should be disabled: %s", rec.Body)
}
}
// The browser flow bdrive login drives: session → /auth/cli redirect with a // The browser flow bdrive login drives: session → /auth/cli redirect with a
// one-time code → exchange for a device token. // one-time code → exchange for a device token.
func TestCLICallbackFlow(t *testing.T) { func TestCLICallbackFlow(t *testing.T) {
+22 -3
View File
@@ -194,7 +194,11 @@ func (a *BuiltinAuth) createAccount(email, name, password string, viaInvite bool
return nil, err return nil, err
} }
status := a.initialStatus() status := a.initialStatus()
if viaInvite { // An invite is an owner's explicit grant; an email on the config's admin
// list is the operator's own. Both are stronger vetting than the signup
// gates, so these accounts activate immediately — otherwise a fresh
// approval-gated hub would strand its first admin as pending forever.
if viaInvite || a.isAdmin(email) {
status = statusActive status = statusActive
} }
a.mu.Lock() a.mu.Lock()
@@ -287,6 +291,12 @@ func (a *BuiltinAuth) isAdmin(email string) bool {
return a.Admins != nil && a.Admins[normEmail(email)] return a.Admins != nil && a.Admins[normEmail(email)]
} }
func (a *BuiltinAuth) accountCount() int {
a.mu.Lock()
defer a.mu.Unlock()
return len(a.users)
}
func (a *BuiltinAuth) verifyPassword(email, password string) *authUser { func (a *BuiltinAuth) verifyPassword(email, password string) *authUser {
a.mu.Lock() a.mu.Lock()
u := a.findByEmail(email) u := a.findByEmail(email)
@@ -712,9 +722,13 @@ func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) {
func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) { func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) {
next := safeNext(r.FormValue("next")) next := safeNext(r.FormValue("next"))
// An invite link authorizes account creation even when public self-signup // An invite link authorizes account creation even when public self-signup
// is closed — it's the only way into an invite-only hub. // is closed — it's the only way into an invite-only hub. Except once: a
// brand-new hub has no accounts to mint an invite with, so until the
// first account exists, the emails on the config's admin list may sign
// up directly (the operator wrote them there — that's the vetting).
inviteTok := a.invitedVia(next) inviteTok := a.invitedVia(next)
if !a.AllowSignup && inviteTok == "" { bootstrap := !a.AllowSignup && inviteTok == "" && len(a.Admins) > 0 && a.accountCount() == 0
if !a.AllowSignup && inviteTok == "" && !bootstrap {
authPage(w, "Sign up disabled", `<p>This server is invite-only. Ask a team owner for an invite link, or sign in if you already have an account.</p> authPage(w, "Sign up disabled", `<p>This server is invite-only. Ask a team owner for an invite link, or sign in if you already have an account.</p>
<p class="alt"><a href="/auth/login">Back to sign in</a></p>`) <p class="alt"><a href="/auth/login">Back to sign in</a></p>`)
return return
@@ -725,6 +739,11 @@ func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) {
if inviteTok != "" { if inviteTok != "" {
signup = a.signupInvited // invite is the vetting: skip gates, activate signup = a.signupInvited // invite is the vetting: skip gates, activate
} }
if bootstrap && !a.isAdmin(r.FormValue("email")) {
signup = func(string, string, string) (*authUser, error) {
return nil, fmt.Errorf("this server is invite-only; only a hub admin can create the first account")
}
}
u, err := signup(r.FormValue("email"), r.FormValue("name"), r.FormValue("password")) u, err := signup(r.FormValue("email"), r.FormValue("name"), r.FormValue("password"))
if err == nil { if err == nil {
switch u.Status { switch u.Status {