diff --git a/services/mana-auth/src/auth/better-auth.config.ts b/services/mana-auth/src/auth/better-auth.config.ts index d84fc6dec..010e6e3b3 100644 --- a/services/mana-auth/src/auth/better-auth.config.ts +++ b/services/mana-auth/src/auth/better-auth.config.ts @@ -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', diff --git a/services/mana-auth/src/spaces/index.ts b/services/mana-auth/src/spaces/index.ts index 0de87628d..218315622 100644 --- a/services/mana-auth/src/spaces/index.ts +++ b/services/mana-auth/src/spaces/index.ts @@ -13,3 +13,11 @@ export { assertSpaceIsDeletable, buildSpaceMetadata, } from './metadata'; + +export { + createPersonalSpaceFor, + candidateSlugFromEmail, + resolveUniqueSlug, + dbSlugTaken, + type SlugTakenLookup, +} from './personal-space'; diff --git a/services/mana-auth/src/spaces/personal-space.spec.ts b/services/mana-auth/src/spaces/personal-space.spec.ts new file mode 100644 index 000000000..ceb7fab10 --- /dev/null +++ b/services/mana-auth/src/spaces/personal-space.spec.ts @@ -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'); + }); +}); diff --git a/services/mana-auth/src/spaces/personal-space.ts b/services/mana-auth/src/spaces/personal-space.ts new file mode 100644 index 000000000..d8d3a7d01 --- /dev/null +++ b/services/mana-auth/src/spaces/personal-space.ts @@ -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; + +/** + * 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 { + const isFree = async (slug: string): Promise => { + 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 }; +}