1.2.4 Auto-create default workspace on user signup (Better-Auth hook)
Estimate: 12m · Depends on: 1.2.2, 1.1.2
Every authenticated user must have at least one workspace (Story-level AC bullet 1). Wire a Better-Auth databaseHooks.user.create.after hook that creates a default workspace named "{user.name}'s Workspace" with the user as its first member, AND a lazy self-heal backfill so a user who somehow ends up with zero workspaces is repaired on next context resolution. The hook covers the user-creating sign-up paths from Story 1.1: email/password sign-up and Google OAuth first sign-in (new user). The Google linking path — an existing email-first user signing in with Google — does NOT create a new User row (Better-Auth's native account.accountLinking with trustedProviders: ['google'] links to the existing user), so the hook correctly never fires there and the pre-existing workspace is preserved.
Why a hook PLUS a backfill, not a hook alone: the obvious design is "the hook is atomic with user creation, so the workspace always exists." That is not true for better-auth@1.6.11 with the Prisma adapter — verified in dist/db/with-hooks.mjs: the create.after hook runs sequentially after the user insert commits, with no shared transaction (the adapter exposes no tx to the hook). So a throw inside the hook leaves a committed user with no workspace — exactly the orphan we want to avoid. Rather than pretend the hook is transactional, design for the real behavior: the hook is the best-effort fast path (covers ~all signups synchronously), and a lazy ensureDefaultWorkspace backfill is the correctness backstop. This also future-proofs against any later sign-up path that bypasses the hook. (Logged as PRODECT_FINDINGS #6 — this card previously asserted false atomicity.)
Why "{user.name}'s Workspace" as the default name: mirrors Linear / Notion / Slack defaults. The user can rename in 1.2.6's settings page anytime. The slug derives from the workspace name via workspacesService.createWorkspace's existing slugify + 4-char-random-suffix-on-collision logic (from 1.2.2), so cross-user name collisions resolve automatically.
What you'll do: Extend lib/auth/index.ts's betterAuth({ ... }) config with a databaseHooks.user.create.after block that calls workspacesService.createWorkspace({ name: ${user.name}'s Workspace, ownerUserId: user.id }) and swallows-and-logs any error (the user row is already committed; throwing here would 500 an otherwise-successful signup — the backfill recovers the miss). Add workspacesService.ensureDefaultWorkspace({ userId, userName }) (idempotent: no-op if the user already has a membership; otherwise creates the default) and wire the resolver behind getWorkspaceContext() to call it when it finds no membership instead of returning null. Add GET /api/workspaces/current (app/api/workspaces/current/route.ts) — a thin route reading the session + active context, returning { workspace, membership } via a service method + DTO (no db.* in the route, per CLAUDE.md's 4-layer rule). Add a Vitest integration test signing up fresh users and asserting workspace + membership exist; extend tests/e2e/auth-credentials.spec.ts to assert /api/workspaces/current returns the auto-created workspace after sign-up.
Acceptance criteria
lib/auth/index.tsextended withdatabaseHooks.user.create.aftercallingworkspacesService.createWorkspacewith the new user's name + id. The hook is best-effort post-commit (NOT atomic with the user insert — verified against better-auth@1.6.11); it swallows-and-logs errors rather than failing the signup response.workspacesService.ensureDefaultWorkspace({ userId, userName })exists and is idempotent (no-op when the user already has a membership; creates the default otherwise). The resolver behindgetWorkspaceContext()calls it when it finds no membership, so a signed-in user is never stranded with zero workspaces — this is the correctness backstop for the non-atomic hook.- All Story-1.1 sign-up paths behave correctly: email/password sign-up and Google OAuth new-user sign-up each produce exactly one workspace; the email-first-then-Google linking path produces NO second workspace (the pre-existing one is preserved; the hook doesn't fire because no User is created).
- A
/api/workspaces/currentroute handler (app/api/workspaces/current/route.ts) returns{ workspace, membership }for the active workspace context, or 401 if no session. Thin transport only — nodb.*/$transactionin the route (4-layer rule). - Default workspace name:
"{user.name}'s Workspace"(e.g."Alice's Workspace"). Slug fromcreateWorkspace's existing slugify + 4-char-random-suffix-on-collision logic (1.2.2). - Vitest integration test in
tests/auto-workspace-on-signup.test.tscovers: email/password sign-up creates a workspace; Google OAuth new-user creates a workspace; email-first user linking Google does NOT create a second workspace;ensureDefaultWorkspacebackfills a zero-workspace user and is idempotent (calling twice yields one workspace). - Playwright E2E spec (
tests/e2e/auth-credentials.spec.ts) extended to assert/api/workspaces/currentreturns the auto-created workspace after sign-up. Existing assertions stay green. - All 4 quality gates green; Vitest + Playwright suites green.
Context refs
lib/auth/index.ts— current Better-Auth config (email/password +socialProviders.google+ nativeaccount.accountLinkingwithtrustedProviders: ['google']); nodatabaseHooksyet — this Subtask adds the blocklib/services/workspacesService.ts—createWorkspace({ name, ownerUserId })is the entry point (atomic Workspace + owner Membership, slug-collision retry). This Subtask addsensureDefaultWorkspacehere. (NOTE: the oldlib/workspaces/repo.ts/lib/users/repo.tsreferenced in pre-1.2.5 cards were deleted by 1.2.5's 4-layer refactor.)lib/workspaces/index.ts+lib/workspaces/middleware.ts—getWorkspaceContext()and the resolver this Subtask hooks the backfill intomotir-core/CLAUDE.md— the 4-layer Route→Service→Repository→Prisma contract the new endpoint + service method must followtests/e2e/auth-credentials.spec.ts+tests/e2e/_helpers/{db-reset,email-capture}.ts— the spec this Subtask extends and its helpers- Better-Auth source (verify, don't assume):
node_modules/better-auth/dist/db/with-hooks.mjs— confirmscreate.afterruns post-commit, not in a shared transaction (the reason the backfill exists)