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.
lib/auth/index.ts extended with databaseHooks.user.create.after calling workspacesService.createWorkspace with 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 behind getWorkspaceContext() 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./api/workspaces/current route handler (app/api/workspaces/current/route.ts) returns { workspace, membership } for the active workspace context, or 401 if no session. Thin transport only — no db.* / $transaction in the route (4-layer rule)."{user.name}'s Workspace" (e.g. "Alice's Workspace"). Slug from createWorkspace's existing slugify + 4-char-random-suffix-on-collision logic (1.2.2).tests/auto-workspace-on-signup.test.ts covers: 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; ensureDefaultWorkspace backfills a zero-workspace user and is idempotent (calling twice yields one workspace).tests/e2e/auth-credentials.spec.ts) extended to assert /api/workspaces/current returns the auto-created workspace after sign-up. Existing assertions stay green.lib/auth/index.ts — current Better-Auth config (email/password + socialProviders.google + native account.accountLinking with trustedProviders: ['google']); no databaseHooks yet — this Subtask adds the blocklib/services/workspacesService.ts — createWorkspace({ name, ownerUserId }) is the entry point (atomic Workspace + owner Membership, slug-collision retry). This Subtask adds ensureDefaultWorkspace here. (NOTE: the old lib/workspaces/repo.ts / lib/users/repo.ts referenced 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 helpersnode_modules/better-auth/dist/db/with-hooks.mjs — confirms create.after runs post-commit, not in a shared transaction (the reason the backfill exists)