feat(spaces): auto-create personal space on signup via Better Auth hook

Wires databaseHooks.user.create.after to call createPersonalSpaceFor,
which provisions a Better Auth organization of type='personal' with the
user as owner. Every signup now produces a usable default space — no
UI code needed to bootstrap it.

Details:
- Slug derived from email local-part, lowercase, alphanumerics + hyphens,
  max 30 chars, random fallback if nothing usable remains.
- Reserved-slug list (me/admin/api/auth/…) blocks system-route clashes.
- Collision resolver appends -2, -3, … up to 999 before falling back to
  a random suffix. Tests cover both the DB-taken and reserved-slug cases
  via an injectable SlugTakenLookup (no DB needed for unit tests).
- Idempotent: if a personal space already exists for the user, returns
  it instead of creating a duplicate. Guards against retry double-signup.
- Failure propagates — an orphan user without a personal space is worse
  than a retry-able signup error.

Existing dev users will need a backfill or a re-provisioning of the dev
DB — new users are unaffected.

12 tests pass (23 total across the spaces module).

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 16:10:26 +02:00
parent 5af96bfeff
commit da1bb2d6e9
4 changed files with 266 additions and 1 deletions

View file

@ -38,7 +38,11 @@ import {
} from '../email/send';
import { sourceAppStore, passwordResetRedirectStore } from './stores';
import { TRUSTED_ORIGINS } from './sso-origins';
import { assertValidSpaceMetadataForCreate, assertSpaceIsDeletable } from '../spaces';
import {
assertValidSpaceMetadataForCreate,
assertSpaceIsDeletable,
createPersonalSpaceFor,
} from '../spaces';
// Re-export so existing imports (`import { TRUSTED_ORIGINS } from './better-auth.config'`)
// keep working. New code should import from './sso-origins' directly.
@ -202,6 +206,32 @@ export function createBetterAuth(databaseUrl: string) {
updateAge: 60 * 60 * 24, // Update session once per day
},
/**
* Database hooks lifecycle callbacks for core tables.
*
* `user.create.after` runs after a successful signup and provisions
* the user's personal Space (a Better Auth organization of type
* `personal`). Every user needs one because modules store private
* data like mood, dreams, sleep there. Failure propagates: an
* orphan user without a personal space is a worse state than a
* retry-able signup error.
*
* See docs/plans/spaces-foundation.md and ../spaces/personal-space.ts.
*/
databaseHooks: {
user: {
create: {
after: async (user) => {
await createPersonalSpaceFor(db, {
id: user.id,
email: user.email,
name: user.name,
});
},
},
},
},
// Base URL for callbacks and redirects
baseURL: process.env.BASE_URL || 'http://localhost:3001',

View file

@ -13,3 +13,11 @@ export {
assertSpaceIsDeletable,
buildSpaceMetadata,
} from './metadata';
export {
createPersonalSpaceFor,
candidateSlugFromEmail,
resolveUniqueSlug,
dbSlugTaken,
type SlugTakenLookup,
} from './personal-space';

View file

@ -0,0 +1,74 @@
/**
* Tests for personal-space slug derivation and uniqueness resolution.
*
* createPersonalSpaceFor is covered by an integration test (DB-backed)
* once that harness exists here we pin down the pure string logic and
* the slug-collision loop.
*/
import { describe, it, expect } from 'bun:test';
import { candidateSlugFromEmail, resolveUniqueSlug, type SlugTakenLookup } from './personal-space';
describe('candidateSlugFromEmail', () => {
it('takes the local part and lowercases it', () => {
expect(candidateSlugFromEmail('Till@memoro.ai')).toBe('till');
expect(candidateSlugFromEmail('Foo.Bar@X.de')).toBe('foo-bar');
});
it('strips non-alphanumerics and collapses dashes', () => {
expect(candidateSlugFromEmail('a..b+c@x.de')).toBe('a-b-c');
});
it('trims leading/trailing dashes', () => {
expect(candidateSlugFromEmail('--till--@x.de')).toBe('till');
});
it('caps at 30 characters', () => {
const long = 'a'.repeat(60) + '@x.de';
const slug = candidateSlugFromEmail(long);
expect(slug.length).toBeLessThanOrEqual(30);
});
it('falls back to a random slug when stripping empties the string', () => {
expect(candidateSlugFromEmail('_____@x.de')).toMatch(/^user-[a-z0-9]{6}$/);
});
it('falls back when local-part contains only whitespace', () => {
expect(candidateSlugFromEmail(' @x.de')).toMatch(/^user-[a-z0-9]{6}$/);
});
it('preserves digits', () => {
expect(candidateSlugFromEmail('user42@x.de')).toBe('user42');
});
});
function lookupFor(taken: string[]): SlugTakenLookup {
const set = new Set(taken);
return async (slug) => set.has(slug);
}
describe('resolveUniqueSlug', () => {
it('returns the base slug when free', async () => {
expect(await resolveUniqueSlug('till', lookupFor([]))).toBe('till');
});
it('appends -2 on single collision', async () => {
expect(await resolveUniqueSlug('till', lookupFor(['till']))).toBe('till-2');
});
it('walks through suffixes until free', async () => {
expect(await resolveUniqueSlug('till', lookupFor(['till', 'till-2', 'till-3']))).toBe('till-4');
});
it('skips reserved slugs even when DB says free', async () => {
expect(await resolveUniqueSlug('admin', lookupFor([]))).toBe('admin-2');
expect(await resolveUniqueSlug('api', lookupFor([]))).toBe('api-2');
expect(await resolveUniqueSlug('me', lookupFor([]))).toBe('me-2');
});
it('does NOT skip non-reserved slugs that happen to contain reserved words', async () => {
// We only match the exact reserved set; `admins`, `apikey`, `myself` are fine.
expect(await resolveUniqueSlug('admins', lookupFor([]))).toBe('admins');
expect(await resolveUniqueSlug('myself', lookupFor([]))).toBe('myself');
});
});

View file

@ -0,0 +1,153 @@
/**
* Personal-Space auto-creation.
*
* Every user gets a Space of type `personal` at signup their private
* default context for modules like mood, dreams, sleep, etc. This module
* implements the creation logic and the slug-collision handling it needs.
*
* Called from `databaseHooks.user.create.after` in better-auth.config.ts.
* If creation fails (e.g. a DB error), the hook propagates the error and
* the signup fails orphan users without a personal space would be a
* worse failure mode than a retry-able signup error.
*/
import { and, eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import { organizations, members } from '../db/schema/organizations';
import type { Database } from '../db/connection';
import { buildSpaceMetadata } from './metadata';
/** Max suffix we try before giving up on collision resolution. */
const MAX_SLUG_SUFFIX = 999;
/** Slugs we never hand out — reserved for system routes or future use. */
const RESERVED_SLUGS = new Set([
'me',
'admin',
'api',
'auth',
'login',
'logout',
'signup',
'signin',
'register',
'settings',
'new',
'app',
'www',
'support',
'help',
'billing',
'invite',
]);
/**
* Turn an email local-part (or any free-form input) into a slug candidate.
* Lowercase, alphanumerics + hyphens only, max 30 chars.
*/
export function candidateSlugFromEmail(email: string): string {
const localPart = email.split('@', 1)[0] ?? '';
const slug = localPart
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 30);
// If stripping left nothing, fall back to a short random string so the
// caller always gets a non-empty base to work from.
return slug || `user-${nanoid(6).toLowerCase()}`;
}
/** Lookup function: returns true iff the given slug is already taken. */
export type SlugTakenLookup = (slug: string) => Promise<boolean>;
/**
* Find a free slug by appending `-2`, `-3`, if the base is taken or
* reserved. Gives up after MAX_SLUG_SUFFIX attempts and falls back to a
* random suffix in practice collision at that scale means something's
* wrong with the base generator, not real contention.
*
* Takes an injectable `isSlugTaken` function so unit tests don't need a
* DB. Production code uses `dbSlugTaken(db)` (below) as the adapter.
*/
export async function resolveUniqueSlug(
base: string,
isSlugTaken: SlugTakenLookup
): Promise<string> {
const isFree = async (slug: string): Promise<boolean> => {
if (RESERVED_SLUGS.has(slug)) return false;
return !(await isSlugTaken(slug));
};
if (await isFree(base)) return base;
for (let i = 2; i <= MAX_SLUG_SUFFIX; i++) {
const candidate = `${base}-${i}`;
if (await isFree(candidate)) return candidate;
}
// Defensive fallback — should never be reached under realistic load.
return `${base}-${nanoid(6).toLowerCase()}`;
}
/** Adapter: turns a Drizzle db into a SlugTakenLookup. */
export function dbSlugTaken(db: Database): SlugTakenLookup {
return async (slug) => {
const existing = await db
.select({ id: organizations.id })
.from(organizations)
.where(eq(organizations.slug, slug))
.limit(1);
return existing.length > 0;
};
}
/**
* Create the personal space for a freshly-registered user.
*
* Idempotent: if the user already owns a space of type `personal`, returns
* its id without creating a second one. Protects against accidental retry
* in the auth signup flow.
*/
export async function createPersonalSpaceFor(
db: Database,
user: { id: string; email: string; name?: string | null }
): Promise<{ organizationId: string; slug: string; created: boolean }> {
// Idempotency guard — check for existing personal space via member join.
const existing = await db
.select({ orgId: organizations.id, slug: organizations.slug, metadata: organizations.metadata })
.from(organizations)
.innerJoin(members, eq(members.organizationId, organizations.id))
.where(eq(members.userId, user.id));
for (const row of existing) {
const meta = row.metadata as { type?: string } | null;
if (meta?.type === 'personal') {
return { organizationId: row.orgId, slug: row.slug ?? '', created: false };
}
}
const base = candidateSlugFromEmail(user.email);
const slug = await resolveUniqueSlug(base, dbSlugTaken(db));
const orgId = nanoid();
const memberId = nanoid();
const displayName = user.name?.trim() || user.email.split('@', 1)[0] || 'Personal';
await db.transaction(async (tx) => {
await tx.insert(organizations).values({
id: orgId,
name: displayName,
slug,
metadata: buildSpaceMetadata('personal'),
logo: null,
});
await tx.insert(members).values({
id: memberId,
organizationId: orgId,
userId: user.id,
role: 'owner',
});
});
return { organizationId: orgId, slug, created: true };
}