feat(server): drop bootstrap-user, first /sign-up becomes the install's identity#902
Conversation
…'s identity Removes the fake `bootstrap-user` (`dev@lobu.local` / `lobudev123`) that start-local.ts used to pre-seed on every fresh PGlite install. That seed was the root cause of the local-install identity fork: the moment an operator visited /sign-up via the web UI with their real email, the Mac app + CLI kept authing as `bootstrap-user` while the web UI showed the new account — same machine, two identities, drift. New model: the first person who signs up IS the install's user. They type a password once at /sign-up; everything after that is passwordless via `/api/local-init` for local processes (Mac app, CLI) and `/api/exchange-token` for the menubar→web handoff. Single-user-mode keeps anyone else from forking the install. Server changes: - start-local.ts: deleted `ensureBootstrapUser` and every BOOTSTRAP_* constant + helper (pickBootstrapIdentity, isLoopbackPgUrl, etc.). ensureDefaultAgent now looks up the first personal-org dynamically instead of targeting a hardcoded BOOTSTRAP_ORG_ID, and is a no-op when no user exists yet. - auth/routes.ts /api/local-init: replaced "seed bootstrap, refuse if real users exist" with "find the single real user, mint for them." Zero users → 404 no_user_yet pointing at /sign-up. >1 users → 404 not_single_user. The `id <> 'bootstrap-user'` filter ignores legacy rows from prior installs so existing users don't get dead-ended. - auth/index.tsx databaseHooks.user.create.before: same legacy filter on the LOBU_SINGLE_USER count so a legacy install with bootstrap-user still allows the first real signup. - auth/index.tsx databaseHooks.user.create.after: runs ensureDefaultAgent immediately on first signup. Without this the first user lands without a default agent until the next `lobu run` boot. - index.ts /api/auth/* middleware: dropped the path-based sign-up guard that blocked the *first* /sign-up when LOBU_SINGLE_USER=1. The DB before-hook is the single chokepoint and is more accurate (covers magic-link verify + OAuth callbacks too). Verified: - `make typecheck` clean. - `bun test packages/server/src/__tests__/unit` — 201 pass, 0 fail. - Codex reviewed (twice). Caught: path-guard-blocked-first-signup, legacy-bootstrap-counted-against-hook, no-default-agent-after-signup. All three fixed in this commit. Known limitation (not fixed): two concurrent first-sign-ups can both observe count=0 and both insert, leaving the install with 2 users and /api/local-init permanently returning not_single_user. Not a practical risk on a local loopback install; clean fix is a DB-level singleton constraint, deferred.
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughThe pull request removes the pre-seeded ChangesBootstrap-user removal and single-user discovery
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint skipped: no ESLint configuration detected in root package.json. To enable, add Comment |
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…ing (#905) * feat(server): passkey (WebAuthn) auth + auth-config flags for local-mode routing Two additions on top of the local-first refactor (#902): 1. @better-auth/passkey plugin enabled in auth/index.tsx ------------------------------------------------------- The operator signs up once with email+password, then can enroll a passkey (Touch ID / Face ID / hardware key) for biometric sign-in. The plugin's default `requireSession: true` for registration is kept — this PR adds the sign-in side; post-signup enrollment UI is a small follow-up. Codex review caught two subtle bugs: - `origin: resolveBaseUrl({ request })` would freeze the WebAuthn origin onto the first request that constructed the cached BA instance — request from localhost could poison passkey for a subsequent Tailscale request. Now `origin: null` (plugin reads the request Origin header at verification time). - `rpID: new URL(resolveBaseUrl({ request })).hostname` had the same freezing problem. Now derived from `PUBLIC_WEB_URL` env (stable per deployment), defaults to "localhost" for local-mode. Migration db/migrations/20260519000000_passkey_table.sql creates the `passkey` table with the schema @better-auth/passkey's adapter expects: publicKey, credentialID, counter, deviceType, backedUp, transports, aaguid. FK userId → user(id) ON DELETE CASCADE. 2. /api/auth-config extended with passkey + singleUserMode + hasUser -------------------------------------------------------------------- SPA currently reads auth-config to gate visible providers (magic-link, social, email-password). After #902 it also needs to know: - `passkey: true` — plugin is always wired, so the SPA can render the Sign-in-with-passkey button unconditionally. - `singleUserMode: env.LOBU_SINGLE_USER === '1'` — drives the SPA's copy + routing for local-first installs. - `hasUser`: SELECT EXISTS(SELECT 1 FROM "user" WHERE id <> 'bootstrap-user') — when singleUserMode + !hasUser, SPA forces /sign-up; when +hasUser, forces /sign-in. Filters out the legacy bootstrap-user row so an install that still has it pre-#902 isn't mis-routed. Submodule bump -------------- packages/owletto → feat/passkey-client (lobu-ai/owletto#8c6c283): - authClient wires passkeyClient(). - login.tsx renders the Sign-in-with-passkey button + checks result.error (third codex finding: BA client returns failure as result.error, not via throw). - login.tsx auto-toggles authIntent based on singleUserMode + hasUser. Verified: - `make typecheck` clean. - `bun test packages/server/src/__tests__/unit` — 201 pass, 0 fail. - Codex reviewed; 3 findings (origin freeze, rpID freeze, result.error not inspected) all fixed before push. Not in this PR (deferred): - Post-signup passkey enrollment prompt — operator needs to sign up with password first, then go to /settings to add a passkey. A one-click "enrol now?" after first sign-up is a follow-up. - Passkey-only signup (no password at all) — requires the plugin's custom `resolveUser` callback + a user-create hook. Bigger lift. * fix: passkey schema snapshot + cli runtime dep CI catches: 1. db/schema.sql must match `dbmate up` output (Schema snapshot drift check). My branch added a migration that creates the `passkey` table but forgot to regenerate db/schema.sql. 2. CLI declares the runtime deps the embedded server bundle pulls at start (dev.test.ts 'CLI package declares runtime deps'). Adding @better-auth/passkey to packages/server/* needs the matching entry in packages/cli/package.json so `lobu run` can resolve it. Hand-edited db/schema.sql to insert only the four passkey blocks (CREATE TABLE, PK, two INDEXes, FK) at the alphabetically correct positions. Avoided a full `make db-schema` regen because my local Postgres has postgis enabled (not in CI's pg16 image) so a full regen would also add postgis extension + geo_lookup function rows that shouldn't be in the snapshot. * fix(schema): place passkey blocks alphabetically to match dbmate output Previous patch put the passkey table after personal_access_tokens, but 'passkey' < 'pending_interactions' < 'personal_access_tokens' alphabetically — dbmate's pg_dump emits them in alpha order, so CI's schema-drift check flagged the mis-positioning. Re-anchored each block: - TABLE: before pending_interactions - PK CONSTRAINT: before pending_interactions_pkey - INDEXes: before personal_access_tokens_active_idx (after oauth_tokens_*) - FK CONSTRAINT: before pending_interactions_organization_id_fkey * chore(submodule): bump owletto to d5ae2a4 (passkey-client merged) * fix(schema): rebuild passkey delta against fresh origin/main schema Previous attempts left duplicate passkey blocks: re-patching after git checkout but before merge layered the new blocks on top of the old wrong-position ones. Reset db/schema.sql to origin/main's clean state, then spliced the 4 passkey blocks once at the correct positions (alphabetical: passkey < pending_interactions < personal_access_tokens) + inserted the 20260519000000 row in INSERT INTO schema_migrations.
- ensureInstallOperator now converges on every boot: if first-boot personal-org provisioning failed, later boots patch it instead of short-circuiting on the existing user row (pi finding #1). - Preserve the legacy bootstrap-user (pre-PR #902) carve-out in every human-count predicate so upgraded installs don't treat it as a real human (pi finding #2). - /api/local-init now fails closed when peerRemoteAddress is missing, guarded by LOBU_LOCAL_INIT_ALLOW_MISSING_PEER=1 for tests only (pi finding #3). - Docs realigned to implementation: member-list / admin user-list carve-outs explicitly NOT in this PR (intentional, scoped via orgs); SPA password-manager mitigations deferred to a follow-up owletto PR; CLI flow corrected to use /api/local-init (which mints the worker PAT, unlike /api/auth/sign-in/email); hashPassword sourced from better-auth/crypto (the actual import) (pi findings #4, #5, #6).
) * feat(auth): install_operator bootstrap — unblock headless installs Fresh lobu run boots with empty user table; CLI calls /api/local-init; server says no_user_yet → no SPA available in /tmp / CI / containers. ensureInstallOperator() auto-provisions a synthetic install_operator user at first boot whose password is the install's ENCRYPTION_KEY. A new principal_kind discriminator on the user table keeps the operator out of human-discovery surfaces (signup count, password reset, magic link, OAuth account-linking). Supersedes #917 (closed): that PR went through 5+ design revisions and accumulated machinery (pairing URL file, single_use PAT, POST /auth/pair-token, /auth/enrol-credential SPA page, custom OTP table) that codex review revealed was redundant with better-auth + browser-native WebAuthn cross-device verification + existing /api/local-init. See docs/install-operator-bootstrap.md for the full design. * docs(a1): explicit Chrome extension via Mac bridge + cross-platform fallback * fix(auth): address pi review on #923 - ensureInstallOperator now converges on every boot: if first-boot personal-org provisioning failed, later boots patch it instead of short-circuiting on the existing user row (pi finding #1). - Preserve the legacy bootstrap-user (pre-PR #902) carve-out in every human-count predicate so upgraded installs don't treat it as a real human (pi finding #2). - /api/local-init now fails closed when peerRemoteAddress is missing, guarded by LOBU_LOCAL_INIT_ALLOW_MISSING_PEER=1 for tests only (pi finding #3). - Docs realigned to implementation: member-list / admin user-list carve-outs explicitly NOT in this PR (intentional, scoped via orgs); SPA password-manager mitigations deferred to a follow-up owletto PR; CLI flow corrected to use /api/local-init (which mints the worker PAT, unlike /api/auth/sign-in/email); hashPassword sourced from better-auth/crypto (the actual import) (pi findings #4, #5, #6). * refactor(auth): trim ceremony in install-operator.ts after audit Audit found that `HUMAN_KIND`, `NOT_INSTALL_OPERATOR_PREDICATE`, and `isInstallOperator` had zero production consumers — they were imported only by the unit test that pinned them against themselves. The carve-out SQL in `auth/index.tsx`, `auth/config.ts`, and `auth/routes.ts` all hand-roll `principal_kind <> 'install_operator'` / `=== "install_operator"` literals; the predicate constant never became a single source of truth. `installOperatorEmail` was also only exported for the unit test; it's a 2-line helper used in one file, so de-export and shrink. Drop the redundant `encryptionKey.length === 0` clause (empty string is already falsy) and inline the `operatorEmail` temporary. Delete the unit test file outright — every remaining subject is either a plain string constant or the integration-tested `ensureInstallOperator`. Coverage for the real behavior lives in the existing integration test (`__tests__/integration/auth/install-operator.test.ts`), which still passes. Net: install-operator.ts 159 → 127 LOC; unit test 64 LOC removed; no behavior change. * fix(install_operator): validate ENCRYPTION_KEY shape at bootstrap Before this fix, `ensureInstallOperator()` only checked that `ENCRYPTION_KEY` was set, then handed it straight to `hashPassword()` — which accepts any string. A 24-byte base64 or other non-canonical value bootstrapped the operator fine, but the at-rest encryption path (provider keys, secrets) requires a canonical 32-byte base64/hex key and 500s with "ENCRYPTION_KEY must be a canonical base64 or hex encoded 32-byte key" on every save. Net result: user could sign in but couldn't persist any encrypted secret. Now the install refuses to start with the same canonical error message the runtime would emit, so the operator either signs in AND can save secrets, or fails fast with an actionable hint (`openssl rand -hex 32` / `openssl rand -base64 32`). - `packages/core/src/utils/encryption.ts`: extract `decodeEncryptionKey` (pure, no side effects) and add `assertEncryptionKey` + `ENCRYPTION_KEY_FORMAT_ERROR` so upstream validators can reuse the exact same shape check the runtime uses. - `packages/server/src/auth/install-operator.ts`: call `assertEncryptionKey` before `hashPassword`. - Integration test: switch the existing fixture to a canonical hex key and add a new case that asserts a malformed key is rejected and no user row is written. * fix(ci): add principal_kind to QUERYABLE_SCHEMA + reset owletto submodule - table-schema: register the new user.principal_kind column added by the install-operator migration so the drift-detection test stops failing. - owletto: reset the submodule pointer to the SHA that lobu/main carries (4f7c757), since this branch's pin (f611c1d) sits behind a real non-bot commit on owletto/main, tripping the Submodule Drift check.
…egacy enc:v1: decoder - Drop `id <> 'bootstrap-user'` filters from auth/config.ts hasUser, auth/routes.ts /local-init, and auth/index.tsx single-user signup guard. The pre-PR #902 bootstrap-user row no longer exists on any supported install; install_operator is the only synthetic row to exclude. - Drop the corresponding "does not count bootstrap-user" case from single-user-signup.test.ts; updated install_operator-only assertion remains. - Delete `decryptLegacyEncryptedConfig` + `ENC_PREFIX` from lobu/stores/postgres-stores.ts and the now-unused `decrypt` import. Prod connections check: `WHERE config::text LIKE '%enc:v1:%'` -> 0 rows. - Drop the `health: { checkIntervalMs, staleThresholdMs, protectActiveWorkers }` block from gateway/config: SOCKET_HEALTH_CHECK_INTERVAL_MS / SOCKET_STALE_THRESHOLD_MS / SOCKET_PROTECT_ACTIVE_WORKERS had zero readers. Also drop the matching `.env.example` blocks (SOCKET_* + Slack manifest-sync vars SLACK_CONFIG_TOKEN/SLACK_CONFIG_REFRESH_TOKEN/SLACK_APP_ID — unreferenced anywhere in packages/). - Drop dead re-exports from gateway/guardrails/index.ts (`BUILTIN_GUARDRAIL_FACTORIES`, `_resetSharedJudgeForTests`, `_setSharedJudgeForTests` — no external consumers); privatize `secretScanGuardrail` / `forbiddenToolsGuardrail` in builtins.ts (used only via the in-file factory map / registerBuiltinGuardrails). - Delete broken settings-auth.test.ts (bun:test file under a vitest mount that was already failing on stale expectations and would be removed by knip). - Remove unused `commander` and `yaml` deps from packages/server/package.json (zero source refs); regenerate bun.lock. - Strip "Phase 5"/"pre-PR #902" historical-context tags from comments in embedded-deployment.ts.
…egacy enc:v1: decoder - Drop `id <> 'bootstrap-user'` filters from auth/config.ts hasUser, auth/routes.ts /local-init, and auth/index.tsx single-user signup guard. The pre-PR #902 bootstrap-user row no longer exists on any supported install; install_operator is the only synthetic row to exclude. - Drop the corresponding "does not count bootstrap-user" case from single-user-signup.test.ts; updated install_operator-only assertion remains. - Delete `decryptLegacyEncryptedConfig` + `ENC_PREFIX` from lobu/stores/postgres-stores.ts and the now-unused `decrypt` import. Prod connections check: `WHERE config::text LIKE '%enc:v1:%'` -> 0 rows. - Drop the `health: { checkIntervalMs, staleThresholdMs, protectActiveWorkers }` block from gateway/config: SOCKET_HEALTH_CHECK_INTERVAL_MS / SOCKET_STALE_THRESHOLD_MS / SOCKET_PROTECT_ACTIVE_WORKERS had zero readers. Also drop the matching `.env.example` blocks (SOCKET_* + Slack manifest-sync vars SLACK_CONFIG_TOKEN/SLACK_CONFIG_REFRESH_TOKEN/SLACK_APP_ID — unreferenced anywhere in packages/). - Drop dead re-exports from gateway/guardrails/index.ts (`BUILTIN_GUARDRAIL_FACTORIES`, `_resetSharedJudgeForTests`, `_setSharedJudgeForTests` — no external consumers); privatize `secretScanGuardrail` / `forbiddenToolsGuardrail` in builtins.ts (used only via the in-file factory map / registerBuiltinGuardrails). - Delete broken settings-auth.test.ts (bun:test file under a vitest mount that was already failing on stale expectations and would be removed by knip). - Remove unused `commander` and `yaml` deps from packages/server/package.json (zero source refs); regenerate bun.lock. - Strip "Phase 5"/"pre-PR #902" historical-context tags from comments in embedded-deployment.ts.
…egacy enc:v1: decoder (#1120) * chore(server): drop pre-#902 bootstrap-user filters, dead env vars, legacy enc:v1: decoder - Drop `id <> 'bootstrap-user'` filters from auth/config.ts hasUser, auth/routes.ts /local-init, and auth/index.tsx single-user signup guard. The pre-PR #902 bootstrap-user row no longer exists on any supported install; install_operator is the only synthetic row to exclude. - Drop the corresponding "does not count bootstrap-user" case from single-user-signup.test.ts; updated install_operator-only assertion remains. - Delete `decryptLegacyEncryptedConfig` + `ENC_PREFIX` from lobu/stores/postgres-stores.ts and the now-unused `decrypt` import. Prod connections check: `WHERE config::text LIKE '%enc:v1:%'` -> 0 rows. - Drop the `health: { checkIntervalMs, staleThresholdMs, protectActiveWorkers }` block from gateway/config: SOCKET_HEALTH_CHECK_INTERVAL_MS / SOCKET_STALE_THRESHOLD_MS / SOCKET_PROTECT_ACTIVE_WORKERS had zero readers. Also drop the matching `.env.example` blocks (SOCKET_* + Slack manifest-sync vars SLACK_CONFIG_TOKEN/SLACK_CONFIG_REFRESH_TOKEN/SLACK_APP_ID — unreferenced anywhere in packages/). - Drop dead re-exports from gateway/guardrails/index.ts (`BUILTIN_GUARDRAIL_FACTORIES`, `_resetSharedJudgeForTests`, `_setSharedJudgeForTests` — no external consumers); privatize `secretScanGuardrail` / `forbiddenToolsGuardrail` in builtins.ts (used only via the in-file factory map / registerBuiltinGuardrails). - Delete broken settings-auth.test.ts (bun:test file under a vitest mount that was already failing on stale expectations and would be removed by knip). - Remove unused `commander` and `yaml` deps from packages/server/package.json (zero source refs); regenerate bun.lock. - Strip "Phase 5"/"pre-PR #902" historical-context tags from comments in embedded-deployment.ts. * docs: scrub remaining bootstrap-user references from install-operator doc + /local-init comment Pi review nits from PR #1120: the install-operator-bootstrap.md predicate examples and the /local-init JSDoc still referenced the dropped `id <> 'bootstrap-user'` filter. Align with the simpler `principal_kind <> 'install_operator'` predicate that the code now uses. * test(server): restore settings-auth coverage for verifySettingsToken/Session The cleanup commit deleted settings-auth.test.ts as 'broken/stale', but it was the only coverage for verifySettingsToken, verifySettingsSession and setSettingsSessionCookie — all still live across ~10 gateway route files. Restore it under src/gateway/__tests__/ (the dir the integration job's 'bun test src/gateway/__tests__' actually runs, alongside the other gateway security bun:test files) and fix the genuinely-stale expectations: - both verify* functions are now async + consult a RevokedTokenStore: await them and inject a fake store via setRevokedTokenStore (no Postgres needed). - the core encryption key is memoized per-process, so the wrong-key rotation test now resets it with __resetEncryptionKeyCacheForTests around each switch. - add revoked-jti kill-switch coverage (token + cookie paths) and a jti-mint assertion for setSettingsSessionCookie. Keeps every security assertion: expiry rejection, exp=0 guard, tamper/wrong-key rejection, httpOnly/SameSite=Lax/Secure cookie flags. 30 tests pass.
Summary
Removes the fake
bootstrap-user(dev@lobu.local/lobudev123) that was pre-seeded on every fresh PGlite install. That seed caused the local-install identity fork that earlier PRs (#896, #898) only worked around: the moment the operator visited/sign-upwith a real email, the Mac app + CLI kept polling asbootstrap-userwhile the web UI showed the new account.New model: first /sign-up = the install's user. Password typed once at signup; everything after that is passwordless via
/api/local-init(Mac, CLI) or/api/exchange-token(menubar→web).LOBU_SINGLE_USER=1(default-on inlobu run) keeps anyone else from forking the install.Pairs of edits
start-local.ts— deletedensureBootstrapUserand all BOOTSTRAP_* constants/helpers.ensureDefaultAgentnow looks up the first personal-org dynamically; no-op when no user yet.auth/routes.ts/api/local-init— replaced "seed bootstrap, refuse if real users exist" with "find the single real user, mint for them." Zero users → 404no_user_yet(signup_url:/sign-up). >1 users → 404not_single_user. Legacybootstrap-userrows filtered out viaid <> 'bootstrap-user'.auth/index.tsxdatabaseHooks.user.create.before — sameid <> 'bootstrap-user'filter on the LOBU_SINGLE_USER count, so legacy installs aren't dead-ended.auth/index.tsxdatabaseHooks.user.create.after — runsensureDefaultAgentimmediately on first signup so the user has an agent without needing a server restart.index.ts/api/auth/ middleware* — dropped the path-based/api/auth/sign-up/*guard that blocked the first signup. DB hook is the single accurate chokepoint.Reproducer
Before: fresh
lobu runseedsbootstrap-user; operator visitslocalhost:8788/sign-up→ DB ends up with 2 users; Mac app's/api/local-init403s because non-bootstrap users exist; falls back to device-code OAuth which needs a password the operator forgot.After: fresh
lobu runseeds nothing; operator visitslocalhost:8788/sign-up→ Better Auth creates the single user via the hook (count 0, allowed) → personal org provisioned → default agent provisioned synchronously → Mac app's/api/local-initmints session+PAT for THE user → Keychain populated → zero password re-types after that initial signup.Test plan
make typecheckclean.bun test packages/server/src/__tests__/unit— 201 pass, 0 fail./sign-up→ Mac app auto-connects → Chrome ext side panel mounts iframe signed-in.Known limitation (deferred)
Two concurrent first-signups can both observe count=0 and both INSERT, leaving 2 users and
/api/local-initpermanently returningnot_single_user. Not a practical risk on a local loopback install; the clean fix is a DB-level partial-unique-indexWHERE id <> 'bootstrap-user', separate PR.Summary by CodeRabbit