Unverified Commit 0c1cc598 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

feat(auth): migrate console login to passwordless

Replace console password auth with a passwordless surface — the pre-session
login doors plus an identifier-first discovery endpoint — and remove the
password paths.

- Login doors (Public, pre-session): email-OTP, passkey assertion, op.console
  login with in-game approval, and setup-token redeem.
- /api/v1/auth/options: identifier-first discovery reporting which console
  methods an email can use. The single sanctioned existence oracle; methods
  are computed with no role branch, so staff and player accounts in the same
  credential state return byte-identical bodies (staffness invisible by
  construction).
- Remove password auth: drop StaffUser.PasswordHash and the /auth/login,
  /auth/change-password and /users/{id}/reset-password endpoints (and test).
- Data layer: UserByEmail, verified-email uniqueness, setup-token store
  (migration 0012).
- Reconcile docs/openapi.yaml with the served surface; the method/path/face/
  tier parity gate (TestOpenAPIMatchesServedRoutes) passes.
- felis TUI: in-game MC bind, owner/break-glass OP provisioning, version.
- Velocity /felis command suite.

Consolidates the accumulated backend migration work; the frontend (panel/)
is left untouched. Full Go tree green on WSL (go build ./... && go test ./...).
parent 627883e8
Loading
Loading
Loading
Loading

AGENTS.md

0 → 100644
+41 −0
Changes for AGENTS.md: 41 added lines, 0 removed lines.
Original line number Diff line number Diff line
# AGENTS.md

This file helps Autohand understand how to work with this project.

## Project Overview

- **Language**: Go
- **Package Manager**: go

## Commands

- **Build**: `go build`
- **Run**: `go run .`
- **Test**: `go test ./...`
- **Format**: `go fmt ./...`
- **Vet**: `go vet ./...`

## Instruction Sources

- Check saved memories and preferences before implementation work.
- Follow this AGENTS.md file for repository-specific guidance.
- AGENTS.md takes precedence over CLAUDE.md when both files provide instructions.

## Code Style

- Follow Go idioms and conventions
- Use short variable names in small scopes
- Handle errors explicitly
- Follow existing patterns in the codebase
- Use meaningful variable and function names
- Add comments for complex logic
- Keep functions focused and small

## Constraints

- Do not modify files outside the project directory
- Ask before making breaking changes
- Prefer editing existing files over creating new ones
- Do not delete files without confirmation
- Keep dependencies minimal - avoid adding new ones without good reason
- Do not commit sensitive data (API keys, secrets, credentials)
+1 −11
Changes for cmd/felis/api.go: 1 added line, 11 removed lines.
Original line number Diff line number Diff line
@@ -8,7 +8,6 @@ import (
	"net/http"
	"os"
	"regexp"
	goruntime "runtime"
	"strings"
	"time"

@@ -169,14 +168,6 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
	// agree on what local auth knows.
	repo := api.NewPGRepo(drv.DB())

	// Bound concurrent login bcrypt to roughly the core count (floored so even a 1–2
	// vCPU demo box tolerates a handful of simultaneous staff logins). bcrypt is
	// CPU-costly and the public login route runs a full compare on every request, so
	// this caps the work a login flood can pile on the scheduler; the excess is shed
	// as a cheap 429. Staff password logins are rare (players never use this path), so
	// the cap never bites legitimate use.
	loginBcryptCap := max(goruntime.NumCPU(), 4)

	a := &api.API{
		Repo:    repo,
		Cluster: api.NewK8sCluster(cl, cfg.K8s.Namespace),
@@ -203,7 +194,6 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
		},
		RootDomain:   cfg.Server.RootDomain,
		WakeCooldown: 30 * time.Second,
		MaxConcurrentLogins: loginBcryptCap,
		// Bound concurrent console/build-log SSE streams per principal. Generous enough
		// for legitimate multi-tab / multi-server watching, while capping how many
		// upstream follow connections a single caller can tie up if their streams stall.
@@ -232,7 +222,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
		fmt.Fprintln(stderr, "felis api: passkey verifier disabled (auth.panel_hostname unset) — passkey endpoints return 503")
	}

	externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain)
	externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain, cfg.Auth.PanelHostname, cfg.Auth.AdminHostname, resolvedVersion())
	internalSrv := newAPIServer(*internalAddr, a.InternalHandler())
	externalSrv := newAPIServer(cfg.Server.Listen, externalHandler)

+101 −146
Changes for cmd/felis/breakglass.go: 101 added lines, 146 removed lines.
Original line number Diff line number Diff line
@@ -3,6 +3,8 @@ package main
import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
@@ -11,13 +13,13 @@ import (
	"io"
	"os"
	"strings"
	"time"

	"felis.lolicon.best/internal/api"
	"felis.lolicon.best/internal/config"
	"felis.lolicon.best/internal/store"

	tea "github.com/charmbracelet/bubbletea"
	"golang.org/x/crypto/bcrypt"
)

// `felis breakGlass` is the local break-glass emergency console (spec §B). Its
@@ -64,27 +66,31 @@ import (
// bare Enter) keeps the unverified root override from happening by reflex.
const breakGlassOverrideToken = "OVERRIDE"

// bootstrapPasswordAlphabet excludes visually ambiguous glyphs (0/O, 1/I/l) so a
// human can transcribe a generated one-time password off a terminal without error.
const bootstrapPasswordAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"

// ownerStore is the minimal repo surface the break-glass console needs.
// ownerStore is the minimal repo surface the break-glass / setup console needs.
// *api.PGRepo satisfies it; the unit tests drive a fake, so the core logic
// (authentication, provisioning, accountability audit) is exercised without a
// (recovery, provisioning, accountability audit) is exercised without a
// database or a terminal.
type ownerStore interface {
	// AdminExists reports whether any authenticatable staff account already exists.
	// AdminExists reports whether any admin account already exists.
	// It is the bootstrap-vs-recovery switch.
	AdminExists(ctx context.Context) (bool, error)
	// UserByUsername loads a staff login projection for credential verification.
	// UserByUsername loads a staff login projection.
	UserByUsername(ctx context.Context, username string) (*api.StaffUser, error)
	UpsertOwner(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error
	UpsertOwner(ctx context.Context, id, username, email string) error
	// InsertOperator mints a NEW Operator staff account. Unlike UpsertOwner it is
	// insert-only: a username already taken is a conflict (api.ErrConflict), never a
	// silent reset, so adding an Operator can never clobber the Owner or an existing
	// Operator. The row is role=admin, identical in shape to the Owner — Felis has no
	// separate operator DB role (migration 0003: staff = role=admin WITH a hash).
	InsertOperator(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error
	// separate operator DB role (migration 0003: staff = role=admin).
	InsertOperator(ctx context.Context, id, username, email string) error
	// RedeemLinkCodeForOwner consumes an in-game link code and creates-or-promotes
	// the bound user to role='admin' (Owner). It is the `felis setup` MC-bind path:
	// the operator enters limbo, runs /link, types the code here, and the bound
	// account becomes the passwordless Owner. Unlike RedeemPlayerBindCode it does NOT
	// refuse staff — setup deliberately elevates the bound account.
	RedeemLinkCodeForOwner(ctx context.Context, newUserID, code string, now time.Time) (userID, mcUUID, authSource string, err error)
	// CreateSetupToken mints a one-time setup token for first-web-login bootstrap.
	CreateSetupToken(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error
	SetSetting(ctx context.Context, key string, value []byte) error
	// Audit records the break-glass accountability row.
	Audit(ctx context.Context, e api.AuditEntry) error
@@ -164,20 +170,14 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
			fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local-password login is ENABLED.\n", res.username)
		}
		fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser)
		if res.displayPassword != "" {
			// A one-time password was generated (recovery / root override). It is shown,
			// never persisted: only the bcrypt hash reached the database.
			fmt.Fprintf(stdout, "One-time password (you MUST change it on first login):\n\n    %s\n\n", res.displayPassword)
		} else {
			// Bootstrap: the operator typed the password themselves, so we do NOT echo it
			// back into scrollback.
			fmt.Fprintln(stdout, "Log in with the password you just entered (you MUST change it on first login).")
		if res.setupTokenURL != "" {
			fmt.Fprintf(stdout, "One-time setup URL (opens a lockdown session to verify email / enroll passkey):\n\n    %s\n\n", res.setupTokenURL)
		}
		if res.auditWarning != "" {
			fmt.Fprintf(stdout, "WARNING: the accountability audit row was NOT written: %s\n", res.auditWarning)
		}
		if url := adminLoginURL(res.rootDomain, res.adminHostname); url != "" {
			fmt.Fprintf(stdout, "Log in at %s with that username and password.\n", url)
			fmt.Fprintf(stdout, "Admin console: %s\n", url)
		}
	}

@@ -229,54 +229,12 @@ func newOwnerID() string {
	return "usr-" + hex.EncodeToString(b[:])
}

// generateBootstrapPassword returns a fresh one-time password from the unambiguous
// alphabet. It rejection-samples to avoid modulo bias, so every position is uniform
// over the alphabet. 20 chars over a 57-symbol alphabet is ~116 bits — far more than
// the must-change credential needs, and it is rotated on first login regardless.
func generateBootstrapPassword() (string, error) {
	const n = 20
	// Largest multiple of the alphabet size that fits in a byte; bytes at or above it
	// are discarded so the surviving values map uniformly (no modulo bias).
	limit := byte(256 - (256 % len(bootstrapPasswordAlphabet)))
	out := make([]byte, 0, n)
	var b [1]byte
	for len(out) < n {
		if _, err := rand.Read(b[:]); err != nil {
			return "", fmt.Errorf("generate bootstrap password: %w", err)
		}
		if b[0] >= limit {
			continue
		}
		out = append(out, bootstrapPasswordAlphabet[int(b[0])%len(bootstrapPasswordAlphabet)])
	}
	return string(out), nil
}

// validateOwnerPassword mirrors api.validateNewPassword (handlers_auth.go): a
// break-glass credential must satisfy the SAME 8–72-byte rule the panel's own
// change-password enforces, so an operator can never set a password here that the
// web change-password flow would later reject. 72 is bcrypt's hard input limit.
func validateOwnerPassword(pw string) error {
	if len(pw) < 8 {
		return errors.New("password must be at least 8 characters")
	}
	if len(pw) > 72 {
		return errors.New("password must be at most 72 bytes")
	}
	return nil
}

// authenticateAdmin verifies a typed credential against an existing admin account
// for recovery-mode attribution. matched is the stored username on success.
//
// ok==false with err==nil is NOT a failure to surface — it means the credential did
// not match any admin password. The caller offers an explicit root override instead
// of refusing, because break-glass must still recover when no admin credential can
// be produced (a forgotten password is the canonical reason the web login is
// unreachable in the first place). Only a real datastore fault returns err.
func authenticateAdmin(ctx context.Context, s ownerStore, username, password string) (matched string, ok bool, err error) {
// authenticateAdmin resolves a typed admin username for recovery-mode attribution.
// Password verification is gone (passwordless design); Phase 3 replaces this with
// email-OTP recovery. For now it confirms the named admin exists.
func authenticateAdmin(ctx context.Context, s ownerStore, username string) (matched string, ok bool, err error) {
	username = strings.TrimSpace(username)
	if username == "" || password == "" {
	if username == "" {
		return "", false, nil
	}
	u, err := s.UserByUsername(ctx, username)
@@ -286,70 +244,47 @@ func authenticateAdmin(ctx context.Context, s ownerStore, username, password str
	if err != nil {
		return "", false, err
	}
	// Only an admin row carrying a bcrypt hash is an authenticatable staff identity;
	// a player row (role=user, hash NULL → empty PasswordHash) can never attribute a
	// break-glass action.
	if u.Role != "admin" || u.PasswordHash == "" {
		return "", false, nil
	}
	if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
	if u.Role != "admin" {
		return "", false, nil
	}
	return u.Username, true, nil
}

// provisionOwner mints or resets the single Owner account direct-to-Postgres with
// the given (already-validated-by-the-caller) password. The account is created with
// must_change_password=true, which is load-bearing: it is what arms the API's
// lockdown middleware so the Owner can do nothing but change the password on first
// login. Only the bcrypt hash reaches the database; the plaintext never does.
func provisionOwner(ctx context.Context, s ownerStore, username, email, password string) error {
// provisionOwner mints or resets the single Owner account direct-to-Postgres,
// passwordless. The account is role=admin with no password — the Owner completes
// passwordless login setup via the web setup-token flow after `felis setup`.
func provisionOwner(ctx context.Context, s ownerStore, username, email string) error {
	username = strings.TrimSpace(username)
	if username == "" {
		return errors.New("owner username is required")
	}
	if err := validateOwnerPassword(password); err != nil {
		return err
	}
	id := newOwnerID()
	if id == "" {
		return errors.New("generate owner id: entropy source failed")
	}
	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		return fmt.Errorf("hash owner password: %w", err)
	}
	if err := s.UpsertOwner(ctx, id, username, strings.TrimSpace(email), string(hash), true); err != nil {
	if err := s.UpsertOwner(ctx, id, username, strings.TrimSpace(email)); err != nil {
		return fmt.Errorf("write owner: %w", err)
	}
	return nil
}

// provisionOperator mints a NEW Operator staff account direct-to-Postgres. Like the
// Owner it requires must_change_password=true but carries role='admin' (the single
// above-admin 'owner' role was added in migration 0011 and is exclusive to the first
// account — every subsequent staff is a plain admin). UNLIKE provisionOwner, which
// username conflict, this is insert-only: a username already taken returns
// api.ErrConflict rather than overwriting a live account, so adding an Operator can
// never silently clobber the Owner's or another Operator's credential. Only the
// bcrypt hash reaches the database; the plaintext never does.
func provisionOperator(ctx context.Context, s ownerStore, username, email, password string) error {
// Owner it is role=admin and passwordless — Felis has no separate operator DB role,
// so an Operator is simply an additional staff admin (migration 0003). UNLIKE
// provisionOwner, which upserts the single Owner and resets it on a username
// conflict, this is insert-only: a username already taken returns api.ErrConflict
// rather than overwriting a live account, so adding an Operator can never silently
// clobber the Owner's or another Operator's account.
func provisionOperator(ctx context.Context, s ownerStore, username, email string) error {
	username = strings.TrimSpace(username)
	if username == "" {
		return errors.New("operator username is required")
	}
	if err := validateOwnerPassword(password); err != nil {
		return err
	}
	id := newOwnerID()
	if id == "" {
		return errors.New("generate operator id: entropy source failed")
	}
	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		return fmt.Errorf("hash operator password: %w", err)
	}
	if err := s.InsertOperator(ctx, id, username, strings.TrimSpace(email), string(hash), true); err != nil {
	if err := s.InsertOperator(ctx, id, username, strings.TrimSpace(email)); err != nil {
		if errors.Is(err, api.ErrConflict) {
			// Wrap %w so errors.Is(err, api.ErrConflict) still holds — the TUI can render
			// a "name already taken" message — while keeping a clear human string.
@@ -381,50 +316,84 @@ type breakGlassOp struct {
	osUser         string // $SUDO_USER (or "root"); recorded in the payload
	ownerUsername  string
	ownerEmail     string
	ownerPassword  string // typed (bootstrap); "" => generate a one-time password
	attemptedAdmin string // recovery / override: the admin username the operator typed
}

// breakGlassOutcome is what performBreakGlass reports back to the TUI.
type breakGlassOutcome struct {
	displayPassword string // non-empty only when a one-time password was generated
	setupTokenURL string // non-empty when setup minted a one-time first-login URL
	auditErr      error  // non-nil if the accountability row could not be written
}

// performBreakGlass executes a resolved break-glass operation: provision (or reset)
// the Owner, enable local-password login, then record a best-effort accountability
// audit row. A typed ownerPassword (bootstrap) is used as-is; an empty one (recovery
// / root override) is replaced with a generated one-time password returned for
// one-time display. The audit write is best-effort: a logging failure is reported
// via auditErr but does NOT fail the recovery — break-glass must still work when the
// audit sink is unhappy.
// audit row. The Owner is passwordless — the setup-token flow handles first-login
// setup. The audit write is best-effort: a logging failure is reported via auditErr
// but does NOT fail the recovery — break-glass must still work when the audit sink
// is unhappy.
func performBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) (breakGlassOutcome, error) {
	password := op.ownerPassword
	generated := false
	if password == "" {
		p, err := generateBootstrapPassword()
		if err != nil {
	if err := provisionOwner(ctx, s, op.ownerUsername, op.ownerEmail); err != nil {
		return breakGlassOutcome{}, err
	}
		password, generated = p, true
	}
	if err := provisionOwner(ctx, s, op.ownerUsername, op.ownerEmail, password); err != nil {
		return breakGlassOutcome{}, err
	}
	// Record accountability the instant the credential changes — BEFORE enabling
	// local auth, which can still fail. Auditing only after both writes would let a
	// failed enableLocalAuth leave a just-reset credential with no "who did it" row;
	// the audit is best-effort, so doing it first never blocks the recovery.
	// Record accountability the instant the account is written — BEFORE enabling
	// local auth, which can still fail. The audit is best-effort, so doing it first
	// never blocks the recovery.
	out := breakGlassOutcome{auditErr: auditBreakGlass(ctx, s, op)}
	if generated {
		out.displayPassword = password
	}
	if err := enableLocalAuth(ctx, s); err != nil {
		return breakGlassOutcome{}, err
	}
	return out, nil
}

// setupTokenTTL bounds how long a one-time setup URL is valid. The operator opens
// it right after setup completes, so a generous-but-bounded window is enough.
const setupTokenTTL = 30 * time.Minute

// newSetupToken returns a fresh opaque setup token (256 bits, URL-safe) and its
// sha-256 hex hash. Only the hash is persisted; the raw value rides in the URL.
func newSetupToken() (raw, hash string, err error) {
	var b [32]byte
	if _, err := rand.Read(b[:]); err != nil {
		return "", "", fmt.Errorf("generate setup token: %w", err)
	}
	raw = base64.RawURLEncoding.EncodeToString(b[:])
	sum := sha256.Sum256([]byte(raw))
	return raw, hex.EncodeToString(sum[:]), nil
}

// performSetupMCBind is the `felis setup` Owner-establishment path: the operator
// binds their Minecraft account via an in-game /link code, the bound user is
// promoted to role='admin' (passwordless Owner), and a one-time setup URL is
// minted for the first web login where the Owner verifies email / enrolls a
// passkey. adminHostname is the op.console host the URL points at.
func performSetupMCBind(ctx context.Context, s ownerStore, code, adminHostname string) (breakGlassOutcome, error) {
	code = strings.TrimSpace(strings.ToUpper(code))
	if code == "" {
		return breakGlassOutcome{}, errors.New("link code is required")
	}
	newID := newOwnerID()
	if newID == "" {
		return breakGlassOutcome{}, errors.New("generate owner id: entropy source failed")
	}
	userID, _, _, err := s.RedeemLinkCodeForOwner(ctx, newID, code, time.Now())
	if err != nil {
		return breakGlassOutcome{}, fmt.Errorf("bind minecraft account: %w", err)
	}
	raw, hash, err := newSetupToken()
	if err != nil {
		return breakGlassOutcome{}, err
	}
	if err := s.CreateSetupToken(ctx, hash, userID, time.Now().Add(setupTokenTTL)); err != nil {
		return breakGlassOutcome{}, fmt.Errorf("mint setup token: %w", err)
	}
	host := strings.TrimSpace(adminHostname)
	if host == "" {
		host = "op.console.localhost"
	}
	url := "https://" + host + "/setup?token=" + raw
	return breakGlassOutcome{setupTokenURL: url}, nil
}

// auditBreakGlass writes the break-glass accountability row. The actor is the
// resolved human identity (a verified admin in recovery, the OS user otherwise);
// the payload carries the full who/what/how so an after-the-fact reader can tell a
@@ -454,9 +423,7 @@ func auditBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) error {
}

// performAddOperator mints a NEW Operator account and records a best-effort
// accountability row. It mirrors performBreakGlass — a typed password is used as-is,
// an empty one is replaced with a generated one-time password returned for one-time
// display (the common case: hand a fresh credential to the new operator) — with two
// accountability row. It mirrors performBreakGlass — passwordless — with two
// deliberate differences. (1) It provisions insert-only (provisionOperator), so it
// can never reset an existing account the way the Owner upsert does. (2) It does NOT
// touch local_auth_enabled: adding an Operator presupposes an already-configured,
@@ -465,22 +432,10 @@ func auditBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) error {
// the Owner break-glass thread alone. The audit is best-effort and written only after
// a successful provision; a conflict mints nothing, so there is nothing to attribute.
func performAddOperator(ctx context.Context, s ownerStore, op breakGlassOp) (breakGlassOutcome, error) {
	password := op.ownerPassword
	generated := false
	if password == "" {
		p, err := generateBootstrapPassword()
		if err != nil {
			return breakGlassOutcome{}, err
		}
		password, generated = p, true
	}
	if err := provisionOperator(ctx, s, op.ownerUsername, op.ownerEmail, password); err != nil {
	if err := provisionOperator(ctx, s, op.ownerUsername, op.ownerEmail); err != nil {
		return breakGlassOutcome{}, err
	}
	out := breakGlassOutcome{auditErr: auditAddOperator(ctx, s, op)}
	if generated {
		out.displayPassword = password
	}
	return out, nil
}

@@ -520,7 +475,7 @@ type breakGlassResult struct {
	accountable   string
	osUser        string
	username      string
	displayPassword string // empty when the operator typed their own bootstrap password
	setupTokenURL string // non-empty when setup minted a one-time first-login URL
	auditWarning  string
	rootDomain    string
	adminHostname string
+217 −216

File changed.

Preview size limit exceeded, changes collapsed.

+21 −6
Changes for cmd/felis/setup.go: 21 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -24,6 +24,15 @@ const hostBootstrapKubeconfigPath = "/etc/rancher/k3s/k3s.yaml"

var errHostBootstrapCancelled = errors.New("host bootstrap cancelled")

// channelName maps the --dev flag to the release channel deploy/bootstrap.sh
// understands. Release is the default so a bare `felis setup` is production.
func channelName(dev bool) string {
	if dev {
		return "dev"
	}
	return "release"
}

// cmdSetup is the normal first-run operator console. It is intentionally separate
// from breakGlass: setup creates the initial Owner and optional web edge; breakGlass
// is reserved for emergency local recovery/reset.
@@ -31,12 +40,20 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
	fs := flag.NewFlagSet("setup", flag.ContinueOnError)
	fs.SetOutput(stderr)
	cfgPath := fs.String("config", defaultSetupConfigPath, "path to felis.toml")
	dev := fs.Bool("dev", false, "install the dev channel (felis:dev, main HEAD) instead of the default release channel (felis:release, newest tag)")
	if err := fs.Parse(args); err != nil {
		if errors.Is(err, flag.ErrHelp) {
			return 0
		}
		return 2
	}
	// The channel governs which image tag/source ref the host bootstrap builds.
	// runBootstrap forwards the whole environment, so exporting it here is enough
	// to reach deploy/bootstrap.sh without threading a parameter through the TUI.
	if err := os.Setenv("FELIS_CHANNEL", channelName(*dev)); err != nil {
		fmt.Fprintf(stderr, "felis setup: %v\n", err)
		return 1
	}
	configFlagSet := false
	fs.Visit(func(f *flag.Flag) {
		if f.Name == "config" {
@@ -112,18 +129,16 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
	}

	if res.provisioned {
		fmt.Fprintf(stdout, "\nfelis setup: Owner account %q provisioned; local-password login is ENABLED.\n", res.username)
		fmt.Fprintf(stdout, "\nfelis setup: Owner account %q provisioned (passwordless).\n", res.username)
		fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser)
		if res.displayPassword != "" {
			fmt.Fprintf(stdout, "One-time password (you MUST change it on first login):\n\n    %s\n\n", res.displayPassword)
		} else {
			fmt.Fprintln(stdout, "Log in with the password you just entered (you MUST change it on first login).")
		if res.setupTokenURL != "" {
			fmt.Fprintf(stdout, "Open this URL to complete passwordless login setup (verify email / enroll passkey):\n\n    %s\n\n", res.setupTokenURL)
		}
		if res.auditWarning != "" {
			fmt.Fprintf(stdout, "WARNING: the accountability audit row was NOT written: %s\n", res.auditWarning)
		}
		if panelURL != "" {
			fmt.Fprintf(stdout, "Log in at %s with that username and password.\n", panelURL)
			fmt.Fprintf(stdout, "Admin console: %s\n", panelURL)
			fmt.Fprintln(stdout, "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit.")
		}
	}
Loading