From 9f15c06f41c1b3dd5032bee5e3edf998d49b4aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Tue, 19 May 2026 02:45:54 +0100 Subject: [PATCH] feat(server): drop bootstrap-user, first /sign-up becomes the install's identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/server/src/auth/index.tsx | 22 ++- packages/server/src/auth/routes.ts | 105 ++++++---- packages/server/src/index.ts | 38 +--- packages/server/src/start-local.ts | 303 +++-------------------------- 4 files changed, 119 insertions(+), 349 deletions(-) diff --git a/packages/server/src/auth/index.tsx b/packages/server/src/auth/index.tsx index e98961daa..1752c9204 100644 --- a/packages/server/src/auth/index.tsx +++ b/packages/server/src/auth/index.tsx @@ -532,7 +532,9 @@ export async function createAuth(env: Env, request?: Request) { const { getDb } = await import("../db/client"); const sql = getDb(); const rows = (await sql` - SELECT count(*)::int AS count FROM "user" + SELECT count(*)::int AS count + FROM "user" + WHERE id <> 'bootstrap-user' `) as unknown as Array<{ count: number }>; const existing = rows[0]?.count ?? 0; if (existing > 0) { @@ -567,6 +569,24 @@ export async function createAuth(env: Env, request?: Request) { console.log( `[Auth] Provisioned personal org ${result.slug} for user ${user.id}`, ); + // Default agent used to be seeded at `lobu run` boot when the + // bootstrap org existed up front. Without that seed, the first + // real signup is the first moment we have an org to provision + // against; do it here so the user lands with an agent ready + // instead of having to restart `lobu run`. Best-effort — + // failure does not block signup, and start-local.ts also runs + // ensureDefaultAgent on next boot as a backstop. + try { + const { ensureDefaultAgent } = await import( + "./default-provisioning" + ); + await ensureDefaultAgent(result.organizationId); + } catch (agentError) { + console.error( + "[Auth] Default-agent provisioning at signup failed:", + agentError, + ); + } } } catch (error) { console.error("[Auth] Failed to provision personal org:", error); diff --git a/packages/server/src/auth/routes.ts b/packages/server/src/auth/routes.ts index a3a230221..2296b1695 100644 --- a/packages/server/src/auth/routes.ts +++ b/packages/server/src/auth/routes.ts @@ -323,31 +323,34 @@ credentialRoutes.get('/exchange-token', async (c) => { }); /** - * Mint a Better Auth session for the embedded-PGlite bootstrap user. + * Mint a Better Auth session + worker PAT for the install's single user. * * Used by the macOS menu bar and the CLI's `local` context — both run on * the same host as the server, both want a credential they can send as * `Authorization: Bearer ` without prompting the user for * an OAuth device flow. * + * Identity model: the install has exactly one user (enforced by + * `LOBU_SINGLE_USER` + the sign-up-blocking hook in auth/index.tsx). + * Whatever email the operator used at /sign-up is the identity; local-init + * finds that user and mints credentials for them. There is no pre-seeded + * placeholder. When the DB has zero users, we return `no_user_yet` and + * point the caller at /sign-up. + * * Trust model: * - Refuses when any `x-forwarded-*` / `forwarded` header is present. A * Tailscale Funnel / ngrok / cloudflared / nginx proxy fronting a * loopback bind sets these — the bind looks local but the *exposure* * isn't, so a public client could otherwise reach this endpoint. - * - Refuses when the deployment has any non-bootstrap users. Real - * deployments mint credentials through OAuth/email signup; the - * bootstrap user only exists on a fresh PGlite install. - * - Refuses when the bootstrap user/org/member trio is missing - * (`ensureBootstrapUser` either hasn't run or the deployment isn't - * bootstrap-shaped). Caller should retry once the server is ready. + * - Refuses when the deployment has more than one user (legacy bootstrap + * row counts as zero — see the `id <> 'bootstrap-user'` filter below). + * - Refuses when the single user has no personal org (shouldn't happen — + * databaseHooks.user.create.after provisions one). * * Returns the session token in the response body too, so non-cookie * clients (CLI persisting to ~/.config/lobu/credentials.json, Mac app * persisting in OAuthCredentials) can send it as Bearer next time. */ -const BOOTSTRAP_USER_ID = 'bootstrap-user'; -const BOOTSTRAP_ORG_ID = 'org-bootstrap-dev'; credentialRoutes.post('/local-init', async (c) => { // Defense-in-depth: the embedded runner defaults to a loopback bind, @@ -407,43 +410,67 @@ credentialRoutes.post('/local-init', async (c) => { } const sql = createDbClientFromEnv(c.env); - const rows = (await sql` - SELECT - EXISTS(SELECT 1 FROM "user" WHERE id = ${BOOTSTRAP_USER_ID}) AS has_user, - EXISTS(SELECT 1 FROM "organization" WHERE id = ${BOOTSTRAP_ORG_ID}) AS has_org, - EXISTS(SELECT 1 FROM "member" - WHERE "userId" = ${BOOTSTRAP_USER_ID} - AND "organizationId" = ${BOOTSTRAP_ORG_ID}) AS has_member, - (SELECT count(*)::int FROM "user" WHERE id <> ${BOOTSTRAP_USER_ID}) AS non_bootstrap_users - `) as unknown as Array<{ - has_user: boolean; - has_org: boolean; - has_member: boolean; - non_bootstrap_users: number; - }>; - const state = rows[0]; - if (!state || !state.has_user || !state.has_org || !state.has_member) { + // Find the single user this install belongs to. The historical design seeded + // a fake `bootstrap-user` ahead of time and minted sessions for it — but + // that created a fork the moment the operator signed up via web with a real + // email (one identity for the Mac app + CLI, another for the web UI). Now we + // skip the seed and mint for whichever real user signed up first; the + // single-user-mode hook in auth/index.tsx prevents anyone else from joining. + // + // Exclude any leftover `bootstrap-user` rows from pre-this-change installs: + // if both still exist, prefer the real user. After this lands, ensureBootstrap- + // User is gone — fresh installs have no `bootstrap-user` row at all. + const userRows = (await sql` + SELECT id, email, name + FROM "user" + WHERE id <> 'bootstrap-user' + ORDER BY "createdAt" ASC + LIMIT 2 + `) as unknown as Array<{ id: string; email: string; name: string }>; + + if (userRows.length === 0) { return c.json( { - error: 'bootstrap_not_provisioned', + error: 'no_user_yet', error_description: - 'bootstrap user/org/member not seeded — server may still be starting, or this is not a PGlite deployment.', + 'No user exists yet on this install. Open the web UI and sign up first; the menubar / CLI will pick up the new user on the next /api/local-init call.', + signup_url: '/sign-up', }, 404 ); } - if (state.non_bootstrap_users > 0) { + if (userRows.length > 1) { return c.json( { - error: 'not_a_bootstrap_deployment', + error: 'not_single_user', error_description: - '/api/local-init is only available before any real users sign up. Sign in normally.', + '/api/local-init is only for single-user local installs. This deployment has multiple users; sign in normally via /api/auth/sign-in/email.', }, 404 ); } + const user = userRows[0]!; + + // Find the user's personal org (provisioned by databaseHooks.user.create.after). + const orgRows = (await sql` + SELECT id, slug, name + FROM "organization" + WHERE (metadata::jsonb)->>'personal_org_for_user_id' = ${user.id} + LIMIT 1 + `) as unknown as Array<{ id: string; slug: string; name: string }>; + const org = orgRows[0]; + if (!org) { + return c.json( + { + error: 'personal_org_missing', + error_description: + "User exists but has no personal org. databaseHooks.user.create.after may not have run; can't mint a worker PAT without an org binding.", + }, + 500 + ); + } - const minted = await mintSessionCookieValue(c, BOOTSTRAP_USER_ID); + const minted = await mintSessionCookieValue(c, user.id); if ('error' in minted) { return c.json( { error: 'session_create_failed', error_description: minted.error }, @@ -460,8 +487,8 @@ credentialRoutes.post('/local-init', async (c) => { // work zero-config. PostgreSQL still holds the truth (PAT hash in // `personal_access_tokens`, session row in `session`); nothing on disk. const workerPat = await new PersonalAccessTokenService(sql).create( - BOOTSTRAP_USER_ID, - BOOTSTRAP_ORG_ID, + user.id, + org.id, 'local-init', { description: 'Auto-minted by POST /api/local-init for local-runner clients.', @@ -479,14 +506,14 @@ credentialRoutes.post('/local-init', async (c) => { device_token: workerPat.token, device_token_scope: workerPat.scope, user: { - id: BOOTSTRAP_USER_ID, - email: 'dev@lobu.local', - name: 'Local Developer', + id: user.id, + email: user.email, + name: user.name, }, organization: { - id: BOOTSTRAP_ORG_ID, - slug: 'dev', - name: 'Local Dev', + id: org.id, + slug: org.slug, + name: org.name, }, }); }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 0def4630e..c5637f0a2 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -498,39 +498,17 @@ app.get('/health/scheduler', async (c) => { /** * Better-Auth routes - * Handles all authentication requests: OAuth, magic link, phone OTP, sessions + * Handles all authentication requests: OAuth, magic link, phone OTP, sessions. * - * Single-user-mode guard (`LOBU_SINGLE_USER=1`): blocks new credential/social - * sign-ups so the bootstrap user can't be forked into a second account. - * Without this guard, a fresh PGlite install where the operator visits - * `/sign-up` ends up with `bootstrap-user` (used by the Mac app + CLI) AND - * the new real user (used by the web UI) — same machine, two identities, - * orphaned worker rows and personal orgs. The Mac app's saved bearer keeps - * working as `bootstrap-user` while the web UI shows the new account, and - * nothing migrates between them. - * - * Sign-IN still works (so the operator can log into the bootstrap user with - * the printed credentials). Sign-OUT, magic-link, OTP, OAuth-callback, etc. - * also pass through — only the sign-up entry points are blocked. + * Single-user-mode enforcement (`LOBU_SINGLE_USER=1`) lives at + * `databaseHooks.user.create.before` (auth/index.tsx), not here. The DB hook + * sees every account-creation path — sign-up/email, magic-link verify, OAuth + * callback — and refuses a second user with a structured `APIError`. A prior + * path-based fast-fail at this layer also blocked the *first* `/sign-up`, + * which made fresh local-first installs unable to register; that guard has + * been removed in favour of the always-correct DB-hook chokepoint. */ app.on(['GET', 'POST'], '/api/auth/*', async (c) => { - if (c.env.LOBU_SINGLE_USER === '1' && c.req.method === 'POST') { - const path = new URL(c.req.url).pathname; - if ( - path === '/api/auth/sign-up/email' || - path === '/api/auth/sign-up' || - path.startsWith('/api/auth/sign-up/') - ) { - return c.json( - { - error: 'sign_up_disabled_in_single_user_mode', - error_description: - 'This install is in single-user mode. Sign in with the bootstrap credentials printed by `lobu run`, or unset LOBU_SINGLE_USER to allow additional accounts.', - }, - 403 - ); - } - } const auth = await createAuth(c.env, c.req.raw); // better-call crashes with "Unexpected end of JSON input" when a POST has // Content-Type: application/json but an empty body. Ensure a valid body. diff --git a/packages/server/src/start-local.ts b/packages/server/src/start-local.ts index ecc6a8764..d2f73a355 100644 --- a/packages/server/src/start-local.ts +++ b/packages/server/src/start-local.ts @@ -14,17 +14,16 @@ // asserts on load, so this must be the first import; see assert-node-version.ts. import './utils/assert-node-version'; -import { fork, spawnSync } from 'node:child_process'; +import { fork } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { existsSync, mkdirSync } from 'node:fs'; import http from 'node:http'; import { createRequire } from 'node:module'; -import os, { homedir } from 'node:os'; +import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import dotenv from 'dotenv'; -import { z } from 'zod'; dotenv.config(); @@ -232,26 +231,30 @@ async function main() { // ─── Bootstrap user ────────────────────────────────────────── // Runs BEFORE listen so that the bootstrap user / org / member rows are - // guaranteed to exist before the first request lands — first-boot UI - // calls would otherwise race the seed and 401 against a not-yet- - // provisioned user. Auth credentials (Better Auth sessions) are minted - // on demand by `POST /api/local-init` once the listener is up. - try { - await ensureBootstrapUser(dbUrl); - } catch (err) { - logger.warn({ err }, 'Bootstrap user seed failed'); - } - - // ─── Default agent (Mac-app onboarding) ────────────────────── - // Auto-provision the Owletto Personal agent for the bootstrap org - // the first time the deployment boots. Sticky against deletion via a - // sentinel in `organization.metadata` — if the user removes the agent - // through the web UI we do NOT recreate it on the next boot. + // No bootstrap-user seed: prior versions pre-created a `bootstrap-user` + // (`dev@lobu.local` / `lobudev123`) ahead of any real signup. That created + // a fork the moment the operator visited /sign-up with a real email — one + // identity for the Mac app + CLI, another for the web UI. Now the operator + // signs up via /sign-up first (web or in the menubar's connect card) and + // `POST /api/local-init` mints credentials for that single real user; the + // single-user-mode hook in auth/index.tsx (PR #898) prevents anyone else + // from joining. // - // Best-effort: failure here does not block boot. The Mac app degrades to - // an empty-agents state instead of failing to start the server. + // ─── Default agent (Mac-app onboarding) ────────────────────── + // Default-agent provisioning is deferred to first-user creation. The + // `databaseHooks.user.create.after` hook in auth/index.tsx provisions the + // personal org; ensureDefaultAgent runs the next time `lobu run` boots + // after the user exists. try { - await ensureDefaultAgent(BOOTSTRAP_ORG_ID); + const personalOrgRows = (await import('postgres')).default(dbUrl, { max: 1 }); + try { + const rows = + (await personalOrgRows`SELECT id FROM "organization" WHERE (metadata::jsonb)->>'personal_org_for_user_id' IS NOT NULL ORDER BY "createdAt" ASC LIMIT 1`) as unknown as Array<{ id: string }>; + const orgId = rows[0]?.id; + if (orgId) await ensureDefaultAgent(orgId); + } finally { + await personalOrgRows.end({ timeout: 1 }); + } } catch (err) { logger.warn({ err }, 'Default-agent provisioning failed'); } @@ -351,264 +354,6 @@ async function applyEmbeddedSchemaPatches(sql: MigrationSqlClient) { } } -// ─── Bootstrap user ──────────────────────────────────────────────── -// -// Seeds a default user, personal org (slug `dev`), member, and credential -// account so the embedded PGlite deployment has a signed-in identity from -// first boot. Self-skips when the user/org/member trio is already present -// or when the deployment has non-bootstrap users (production safety — -// real signups land via the web UI and own all subsequent boots). -// -// The auth credential itself (a Better Auth session token) is minted on -// demand via `POST /api/local-init` once the HTTP listener is up — -// CLI clients and the macOS menu bar both hit that endpoint instead of -// reading a long-lived token from disk. PostgreSQL holds the truth: the -// `session` table on issuance, the `user` row here on seeding. - -const BOOTSTRAP_USER_ID = 'bootstrap-user'; -// Fallback identity when no env override / git config is available. Needs a -// dotted domain — better-auth's email validator rejects bare `dev@local`. -const BOOTSTRAP_DEFAULT_EMAIL = 'dev@lobu.local'; -const BOOTSTRAP_DEFAULT_NAME = 'Local Developer'; -const BOOTSTRAP_DEFAULT_USERNAME = 'dev-local'; -const BOOTSTRAP_ORG_ID = 'org-bootstrap-dev'; -const BOOTSTRAP_ORG_SLUG = 'dev'; -const BOOTSTRAP_ORG_NAME = 'Local Dev'; -const BOOTSTRAP_MEMBER_ID = 'member-bootstrap-dev'; -// Fixed credential-login password for the bootstrap user. Local PGlite only — -// the user-count guard below means this never lands in a real deployment. -// Must be >= 8 chars to satisfy the web login form's minlength validation. -const BOOTSTRAP_PASSWORD = 'lobudev123'; -const BOOTSTRAP_ACCOUNT_ID = 'account-bootstrap-dev'; - -interface BootstrapIdentity { - email: string; - name: string; - username: string; -} - -/** - * Pick the bootstrap user's identity for a fresh local install. - * - * Order of preference: - * 1. `LOBU_LOCAL_EMAIL` / `LOBU_LOCAL_NAME` env vars (operator-set). - * 2. `git config user.email` / `git config user.name` (most likely correct - * for the dev running `lobu run` out of a checkout). - * 3. `${USER}@${hostname}.local` fallback (no Apple Push push-mail goes - * anywhere — better-auth just needs a dotted domain). - * 4. The original `dev@lobu.local` / "Local Developer" defaults. - * - * The bootstrap user only exists on a fresh PGlite install and is gated to - * loopback, so a wrong-guess email here can't reach a real mailbox. The - * point is to show the operator's actual identity in the menubar + web UI - * instead of a hardcoded placeholder. - */ -function pickBootstrapIdentity(): BootstrapIdentity { - const envEmail = process.env.LOBU_LOCAL_EMAIL?.trim(); - const envName = process.env.LOBU_LOCAL_NAME?.trim(); - - const gitEmail = safeRead(() => - spawnSync('git', ['config', '--get', 'user.email']).stdout.toString().trim() - ); - const gitName = safeRead(() => - spawnSync('git', ['config', '--get', 'user.name']).stdout.toString().trim() - ); - - const osUser = process.env.USER?.trim() || process.env.USERNAME?.trim(); - const osHost = os.hostname()?.trim(); - const osFallbackEmail = - osUser && osHost ? `${osUser.toLowerCase()}@${osHost.toLowerCase()}.local` : null; - - const email = pickFirstValidEmail([envEmail, gitEmail, osFallbackEmail]) ?? BOOTSTRAP_DEFAULT_EMAIL; - const name = envName || gitName || osUser || BOOTSTRAP_DEFAULT_NAME; - const username = usernameFromEmail(email) ?? BOOTSTRAP_DEFAULT_USERNAME; - return { email, name, username }; -} - -function safeRead(fn: () => string): string | null { - try { - const v = fn(); - return v ? v : null; - } catch { - return null; - } -} - -// Use zod's email validator — it's what Better Auth's sign-in/sign-up -// schemas use internally, so anything that passes here is also accepted -// by `/api/auth/sign-in/email`. A weaker regex (e.g. `^[^@]+@[^@]+\.[^@]+$`) -// lets values like `a@b.c`, `user@[127.0.0.1]`, `foo@bar..com` through, -// which fail BA's Zod and leave us with a seeded user whose printed -// credentials can't actually be used. -const emailSchema = z.string().email(); - -function pickFirstValidEmail(candidates: Array): string | null { - for (const c of candidates) { - if (c && emailSchema.safeParse(c).success) return c; - } - return null; -} - -function usernameFromEmail(email: string): string | null { - const local = email.split('@')[0]?.toLowerCase(); - if (!local) return null; - // Match the slug shape personal-org-provisioning uses for collision safety. - const slug = local.replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, ''); - return slug || null; -} - -function isLoopbackPgUrl(dbUrl: string): boolean { - try { - const { hostname } = new URL(dbUrl); - return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1'; - } catch { - return false; - } -} - -async function ensureBootstrapUser(dbUrl: string): Promise { - // Defense-in-depth: this entrypoint spawns its own PGlite at 127.0.0.1 - // (see line 120 above). If the dbUrl ever points elsewhere — someone - // refactors and reuses ensureBootstrapUser against a real DB — refuse - // to seed. The user-count guard below is the second layer; this catches - // the case where a fresh prod DB hasn't had its first signup yet. - if (!isLoopbackPgUrl(dbUrl)) { - logger.warn( - { dbUrl: dbUrl.replace(/:[^:@/]*@/, ':***@') }, - 'Skipping bootstrap user seed — dbUrl is not the local PGlite loopback' - ); - return; - } - - // Reuse the same dynamic-import shape `runMigrations` above uses so we share - // postgres' module init cost with that path on first boot. - const pg = await import('postgres'); - const sql = pg.default(dbUrl, { max: 1 }); - - try { - // Stale-state detection: previously this early-returned whenever a PAT - // file existed on disk, but a wiped LOBU_DATA_DIR could leave rows - // missing. Check all three rows (user + org + member) — if ANY is - // missing, re-seed to restore consistency. - const stateRows = await sql< - [{ user_exists: boolean; org_exists: boolean; member_exists: boolean }] - >` - SELECT - EXISTS(SELECT 1 FROM "user" WHERE id = ${BOOTSTRAP_USER_ID}) AS user_exists, - EXISTS(SELECT 1 FROM "organization" WHERE id = ${BOOTSTRAP_ORG_ID}) AS org_exists, - EXISTS(SELECT 1 FROM "member" WHERE id = ${BOOTSTRAP_MEMBER_ID}) AS member_exists - `; - const allPresent = - stateRows[0]?.user_exists && stateRows[0]?.org_exists && stateRows[0]?.member_exists; - if (allPresent) { - logger.info( - { org: BOOTSTRAP_ORG_SLUG }, - 'Bootstrap user + org + member already provisioned' - ); - return; - } - if (stateRows[0]?.user_exists || stateRows[0]?.org_exists || stateRows[0]?.member_exists) { - logger.warn( - stateRows[0], - 'Bootstrap state is partial — re-seeding to restore consistency' - ); - } - - // Production safety: skip when OTHER users exist. A deployment that has - // real users provisioned via the web UI must not get a "Local Developer" - // user grafted in alongside them. (The bootstrap-user check above doesn't - // catch this — those other-user rows have different ids.) - const otherUserCountRows = await sql<[{ count: number }]>` - SELECT count(*)::int AS count FROM "user" WHERE id <> ${BOOTSTRAP_USER_ID} - `; - if ((otherUserCountRows[0]?.count ?? 0) > 0) { - logger.debug( - { userCount: otherUserCountRows[0]?.count }, - 'Skipping bootstrap user seed — deployment already has non-bootstrap users' - ); - return; - } - - // Pick the operator's actual identity from env / git config / OS user - // before seeding. The bootstrap user only exists on fresh PGlite installs - // (guarded above), and is loopback-only — wrong-guess emails can't reach - // a real mailbox. The point is to show a real identity in the menubar + - // web UI instead of a hardcoded `dev@lobu.local` placeholder. - const identity = pickBootstrapIdentity(); - - // Idempotent user/org/member upsert. Re-runs of the embedded schema (e.g. - // LOBU_DATA_DIR pre-existing without the PAT file) skip ON CONFLICT. - await sql` - INSERT INTO "user" (id, name, email, username, "emailVerified", "createdAt", "updatedAt") - VALUES ( - ${BOOTSTRAP_USER_ID}, - ${identity.name}, - ${identity.email}, - ${identity.username}, - true, - NOW(), - NOW() - ) - ON CONFLICT (id) DO NOTHING - `; - - const metadata = JSON.stringify({ personal_org_for_user_id: BOOTSTRAP_USER_ID }); - await sql` - INSERT INTO "organization" (id, name, slug, visibility, metadata, "createdAt") - VALUES ( - ${BOOTSTRAP_ORG_ID}, - ${BOOTSTRAP_ORG_NAME}, - ${BOOTSTRAP_ORG_SLUG}, - 'private', - ${metadata}, - NOW() - ) - ON CONFLICT (id) DO NOTHING - `; - - await sql` - INSERT INTO "member" (id, "userId", "organizationId", role, "createdAt") - VALUES ( - ${BOOTSTRAP_MEMBER_ID}, - ${BOOTSTRAP_USER_ID}, - ${BOOTSTRAP_ORG_ID}, - 'owner', - NOW() - ) - ON CONFLICT (id) DO NOTHING - `; - - // Credential login for the web UI — same user, fixed password. Uses - // better-auth's default password hasher so `/api/auth/sign-in/email` - // accepts it. `emailVerified` was set true above, so no verification gate. - const { hashPassword } = await import('better-auth/crypto'); - const passwordHash = await hashPassword(BOOTSTRAP_PASSWORD); - await sql` - INSERT INTO "account" (id, "accountId", "providerId", "userId", password, "createdAt", "updatedAt") - VALUES ( - ${BOOTSTRAP_ACCOUNT_ID}, - ${BOOTSTRAP_USER_ID}, - 'credential', - ${BOOTSTRAP_USER_ID}, - ${passwordHash}, - NOW(), - NOW() - ) - ON CONFLICT ("providerId", "accountId") DO NOTHING - `; - - const url = `http://localhost:${PORT}`; - process.stdout.write( - `[bootstrap login] ${identity.email} / ${BOOTSTRAP_PASSWORD} → ${url}\n` - ); - logger.info( - { org: BOOTSTRAP_ORG_SLUG, url }, - 'Bootstrap user + web credential login seeded' - ); - } finally { - await sql.end(); - } -} // ─── Embeddings (child process) ──────────────────────────────────