mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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:
parent
5af96bfeff
commit
da1bb2d6e9
4 changed files with 266 additions and 1 deletions
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -13,3 +13,11 @@ export {
|
|||
assertSpaceIsDeletable,
|
||||
buildSpaceMetadata,
|
||||
} from './metadata';
|
||||
|
||||
export {
|
||||
createPersonalSpaceFor,
|
||||
candidateSlugFromEmail,
|
||||
resolveUniqueSlug,
|
||||
dbSlugTaken,
|
||||
type SlugTakenLookup,
|
||||
} from './personal-space';
|
||||
|
|
|
|||
74
services/mana-auth/src/spaces/personal-space.spec.ts
Normal file
74
services/mana-auth/src/spaces/personal-space.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
153
services/mana-auth/src/spaces/personal-space.ts
Normal file
153
services/mana-auth/src/spaces/personal-space.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue