From 623da892d2df1986cdaf51ed7025a3c7fddb42e5 Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Tue, 21 Jul 2026 23:24:25 -0700 Subject: [PATCH] =?UTF-8?q?fix(auth):=20first-account=20bootstrap=20?= =?UTF-8?q?=E2=80=94=20admin=20emails=20activate=20on=20signup;=20add=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 21 +++++++++++++ docs/self-hosting.md | 15 ++++++---- internal/webapp/auth_test.go | 57 ++++++++++++++++++++++++++++++++++++ internal/webapp/authlocal.go | 25 ++++++++++++++-- 4 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..64607d1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 ./... diff --git a/docs/self-hosting.md b/docs/self-hosting.md index fc09c4c..05cfefb 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -31,8 +31,9 @@ hub over HTTPS. "upload": true, "auth": { // Signup is invite-only by default — the safe posture for a public - // URL. Your first account: start once with signup gated (or use an - // org invite), then invite teammates from the web UI. + // URL. Bootstrap: while the hub has zero accounts, the emails listed + // in "admins" can sign up directly; then invite teammates from the + // web UI. "admins": ["you@example.com"], "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 -1. Open `https://your-hub/` → create your account (first run: use the - signup posture you configured; hub admins are the `admins` emails). +1. Open `https://your-hub/` → sign up with one of 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 in the folder you want synced: `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 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 -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 hub that has none, so a fake email can never just walk in. Three postures: diff --git a/internal/webapp/auth_test.go b/internal/webapp/auth_test.go index c54d604..66afbf8 100644 --- a/internal/webapp/auth_test.go +++ b/internal/webapp/auth_test.go @@ -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 // one-time code → exchange for a device token. func TestCLICallbackFlow(t *testing.T) { diff --git a/internal/webapp/authlocal.go b/internal/webapp/authlocal.go index b55bce9..c66e1d0 100644 --- a/internal/webapp/authlocal.go +++ b/internal/webapp/authlocal.go @@ -194,7 +194,11 @@ func (a *BuiltinAuth) createAccount(email, name, password string, viaInvite bool return nil, err } 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 } a.mu.Lock() @@ -287,6 +291,12 @@ func (a *BuiltinAuth) isAdmin(email string) bool { 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 { a.mu.Lock() 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) { next := safeNext(r.FormValue("next")) // 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) - 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", `

This server is invite-only. Ask a team owner for an invite link, or sign in if you already have an account.

Back to sign in

`) return @@ -725,6 +739,11 @@ func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) { if inviteTok != "" { 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")) if err == nil { switch u.Status {