diff --git a/packages/owletto b/packages/owletto index b579520a8..c5db525a3 160000 --- a/packages/owletto +++ b/packages/owletto @@ -1 +1 @@ -Subproject commit b579520a8767fd4daeea51a3a48d13465e501e57 +Subproject commit c5db525a37e69d7fdb18315b16e24a5776862dd1 diff --git a/packages/server/src/__tests__/integration/auth/personal-org-username.test.ts b/packages/server/src/__tests__/integration/auth/personal-org-username.test.ts new file mode 100644 index 000000000..77bf20bce --- /dev/null +++ b/packages/server/src/__tests__/integration/auth/personal-org-username.test.ts @@ -0,0 +1,99 @@ +/** + * Integration tests for the username side effect of + * `ensurePersonalOrganization`. The frontend resolves a user's home org from + * `session.user.username` (personalOrgSlug); mirroring the personal org slug + * onto username lets the home route resolve synchronously instead of waiting + * on `/api/organizations`. Must be set-when-null, idempotent, and never + * collide with another user's username. + */ + +import { beforeEach, describe, expect, it } from "vitest"; +import { generateSecureToken } from "../../../auth/oauth/utils"; +import { ensurePersonalOrganization } from "../../../auth/personal-org-provisioning"; +import { cleanupTestDatabase, getTestDb } from "../../setup/test-db"; + +async function seedUser(opts: { + name: string; + username?: string | null; +}): Promise<{ id: string; email: string }> { + const id = `user_${generateSecureToken(6)}`; + const email = `${id}@test.local`; + const sql = getTestDb(); + await sql` + INSERT INTO "user" (id, name, email, username, "emailVerified", "createdAt", "updatedAt") + VALUES (${id}, ${opts.name}, ${email}, ${opts.username ?? null}, true, NOW(), NOW()) + `; + return { id, email }; +} + +async function readUsername(userId: string): Promise { + const sql = getTestDb(); + const rows = + await sql`SELECT username FROM "user" WHERE id = ${userId} LIMIT 1`; + return (rows[0]?.username as string | null) ?? null; +} + +describe("ensurePersonalOrganization username backfill", () => { + beforeEach(async () => { + await cleanupTestDatabase(); + }); + + it("sets username to the personal org slug when unset, and creates an owner membership", async () => { + const { id, email } = await seedUser({ + name: "Backfill Me", + username: null, + }); + + const res = await ensurePersonalOrganization({ + id, + email, + name: "Backfill Me", + username: null, + }); + expect(res.created).toBe(true); + expect(res.slug).toBe("backfill-me"); + + expect(await readUsername(id)).toBe(res.slug); + + const sql = getTestDb(); + const members = await sql` + SELECT role FROM "member" WHERE "userId" = ${id} AND "organizationId" = ${res.organizationId} + `; + expect(members).toHaveLength(1); + expect(String(members[0].role)).toBe("owner"); + }); + + it("does not overwrite an existing username (idempotent)", async () => { + const { id, email } = await seedUser({ + name: "Has Handle", + username: "custom-handle", + }); + + await ensurePersonalOrganization({ + id, + email, + name: "Has Handle", + username: "custom-handle", + }); + + expect(await readUsername(id)).toBe("custom-handle"); + }); + + it("leaves username null when the slug is already taken by another user", async () => { + // User A squats the username 'clash' (no org). + await seedUser({ name: "A", username: "clash" }); + // User B's personal org slug derives to 'clash'. + const { id, email } = await seedUser({ name: "Clash", username: null }); + + const res = await ensurePersonalOrganization({ + id, + email, + name: "Clash", + username: null, + }); + expect(res.slug).toBe("clash"); + + // The NOT EXISTS guard prevents stealing A's username; B stays null. + expect(await readUsername(id)).toBeNull(); + }); +}); diff --git a/packages/server/src/auth/personal-org-provisioning.ts b/packages/server/src/auth/personal-org-provisioning.ts index 5296c82a3..14702c444 100644 --- a/packages/server/src/auth/personal-org-provisioning.ts +++ b/packages/server/src/auth/personal-org-provisioning.ts @@ -7,16 +7,16 @@ * to receive auto-installed agents or per-user mirrored schemas. */ -import { getDb } from '../db/client'; -import { RESERVED_PATHS_SET } from '../utils/reserved'; -import { generateSecureToken } from './oauth/utils'; -import { provisionMemberAndCoreIdentities } from './subject-identities'; +import { getDb } from "../db/client"; +import { RESERVED_PATHS_SET } from "../utils/reserved"; +import { generateSecureToken } from "./oauth/utils"; +import { provisionMemberAndCoreIdentities } from "./subject-identities"; interface UserLike { - id: string; - email?: string | null; - name?: string | null; - username?: string | null; + id: string; + email?: string | null; + name?: string | null; + username?: string | null; } // Reserved owner-slug names. Re-exports the canonical set from utils/reserved @@ -36,58 +36,60 @@ const PERSONAL_ORG_LOCK_NAMESPACE = 0x706f7267; // "porg" type Sql = ReturnType; export function slugify(input: string): string { - return input - .toLowerCase() - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, MAX_SLUG_LENGTH); + return input + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, MAX_SLUG_LENGTH); } export function deriveSlugCandidate(user: UserLike): string { - const candidates = [user.username, user.name, user.email?.split('@')[0]]; - for (const raw of candidates) { - if (!raw) continue; - const s = slugify(raw); - if (s) return s; - } - return `user-${user.id.slice(0, 8).toLowerCase()}`; + const candidates = [user.username, user.name, user.email?.split("@")[0]]; + for (const raw of candidates) { + if (!raw) continue; + const s = slugify(raw); + if (s) return s; + } + return `user-${user.id.slice(0, 8).toLowerCase()}`; } async function findAvailableSlug(base: string, sql: Sql): Promise { - const safeBase = RESERVED_SLUGS.has(base) ? `${base}-1` : base; - let candidate = safeBase; - for (let attempt = 0; attempt < MAX_COLLISION_ATTEMPTS; attempt++) { - if (!RESERVED_SLUGS.has(candidate)) { - const rows = await sql` + const safeBase = RESERVED_SLUGS.has(base) ? `${base}-1` : base; + let candidate = safeBase; + for (let attempt = 0; attempt < MAX_COLLISION_ATTEMPTS; attempt++) { + if (!RESERVED_SLUGS.has(candidate)) { + const rows = await sql` SELECT 1 FROM "organization" WHERE slug = ${candidate} LIMIT 1 `; - if (rows.length === 0) return candidate; - } - candidate = `${safeBase}-${attempt + 2}`; - } - // Last-resort suffix — astronomically unlikely to reach this branch. - return `${safeBase}-${generateSecureToken(4).toLowerCase().replace(/[^a-z0-9]/g, '')}`; + if (rows.length === 0) return candidate; + } + candidate = `${safeBase}-${attempt + 2}`; + } + // Last-resort suffix — astronomically unlikely to reach this branch. + return `${safeBase}-${generateSecureToken(4) + .toLowerCase() + .replace(/[^a-z0-9]/g, "")}`; } interface EnsureResult { - organizationId: string; - slug: string; - created: boolean; + organizationId: string; + slug: string; + created: boolean; } export function personalOrgLockKey(userId: string): number { - let hash = 0x811c9dc5; - for (let i = 0; i < userId.length; i++) { - hash ^= userId.charCodeAt(i); - hash = Math.imul(hash, 0x01000193); - } - return hash | 0; + let hash = 0x811c9dc5; + for (let i = 0; i < userId.length; i++) { + hash ^= userId.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return hash | 0; } async function lockPersonalOrgForUser(userId: string, sql: Sql): Promise { - await sql` + await sql` SELECT pg_advisory_xact_lock( ${PERSONAL_ORG_LOCK_NAMESPACE}, ${personalOrgLockKey(userId)} @@ -96,84 +98,122 @@ async function lockPersonalOrgForUser(userId: string, sql: Sql): Promise { } export async function findExistingPersonalOrg( - userId: string, - sql: Sql + userId: string, + sql: Sql, ): Promise<{ id: string; slug: string } | null> { - // Idempotency: an org tagged with this user.id in metadata is already this - // user's personal one. Re-running the hook (e.g. after a transient failure) - // is a no-op. The ORDER BY keeps resolution deterministic if legacy data ever - // contains duplicates. - // organization.metadata is `text` storing JSON; cast to jsonb and use ->> - // instead of LIKE so a userId containing % or _ can't match unintended rows. - const existing = await sql` + // Idempotency: an org tagged with this user.id in metadata is already this + // user's personal one. Re-running the hook (e.g. after a transient failure) + // is a no-op. The ORDER BY keeps resolution deterministic if legacy data ever + // contains duplicates. + // organization.metadata is `text` storing JSON; cast to jsonb and use ->> + // instead of LIKE so a userId containing % or _ can't match unintended rows. + const existing = await sql` SELECT id, slug FROM "organization" WHERE metadata IS NOT NULL AND (metadata::jsonb)->>'personal_org_for_user_id' = ${userId} ORDER BY "createdAt" ASC, id ASC LIMIT 1 `; - if (existing.length === 0) return null; - return existing[0] as { id: string; slug: string }; + if (existing.length === 0) return null; + return existing[0] as { id: string; slug: string }; } -export async function ensurePersonalOrganization(user: UserLike): Promise { - const sql = getDb(); - let result: EnsureResult | null = null; - - await sql.begin(async (tx) => { - await lockPersonalOrgForUser(user.id, tx); - - const existing = await findExistingPersonalOrg(user.id, tx); - if (existing) { - result = { organizationId: existing.id, slug: existing.slug, created: false }; - return; - } - - const baseSlug = deriveSlugCandidate(user); - const slug = await findAvailableSlug(baseSlug, tx); - const orgId = `org_${generateSecureToken(8)}`; - const memberId = `member_${generateSecureToken(8)}`; - const orgName = user.name?.trim() || user.email?.split('@')[0] || slug; - const metadata = JSON.stringify({ personal_org_for_user_id: user.id }); - - await tx` +export async function ensurePersonalOrganization( + user: UserLike, +): Promise { + const sql = getDb(); + let result: EnsureResult | null = null; + + await sql.begin(async (tx) => { + await lockPersonalOrgForUser(user.id, tx); + + const existing = await findExistingPersonalOrg(user.id, tx); + if (existing) { + result = { + organizationId: existing.id, + slug: existing.slug, + created: false, + }; + return; + } + + const baseSlug = deriveSlugCandidate(user); + const slug = await findAvailableSlug(baseSlug, tx); + const orgId = `org_${generateSecureToken(8)}`; + const memberId = `member_${generateSecureToken(8)}`; + const orgName = user.name?.trim() || user.email?.split("@")[0] || slug; + const metadata = JSON.stringify({ personal_org_for_user_id: user.id }); + + await tx` INSERT INTO "organization" (id, name, slug, visibility, metadata, "createdAt") VALUES (${orgId}, ${orgName}, ${slug}, 'private', ${metadata}, NOW()) `; - await tx` + await tx` INSERT INTO "member" (id, "userId", "organizationId", role, "createdAt") VALUES (${memberId}, ${user.id}, ${orgId}, 'owner', NOW()) `; - result = { organizationId: orgId, slug, created: true }; - }); - - const finalResult = result as EnsureResult | null; - if (!finalResult) { - throw new Error('Personal organization transaction did not produce a result'); - } - - // Provision the $member entity + core identifiers (auth_user_id, email) - // outside the transaction. ensureMemberEntity uses createEntity which - // manages its own transaction and seeds the $member entity type if absent. - // Failures here shouldn't roll back the org creation — the user has a - // valid org, identity rows can be backfilled later. Run this for both newly - // created and pre-existing personal orgs; the helper is idempotent. - if (user.email) { - try { - await provisionMemberAndCoreIdentities(finalResult.organizationId, { - userId: user.id, - email: user.email, - name: user.name, - }); - } catch (error) { - console.error('[Auth] Failed to provision $member entity for personal org:', { - orgId: finalResult.organizationId, - userId: user.id, - error: String(error), - }); - } - } - - return finalResult; + result = { organizationId: orgId, slug, created: true }; + }); + + const finalResult = result as EnsureResult | null; + if (!finalResult) { + throw new Error( + "Personal organization transaction did not produce a result", + ); + } + + // Mirror the personal org slug onto the user's username when unset. The + // frontend resolves a user's home org from session.user.username + // (personalOrgSlug); having it set means the home route can route to the + // personal org synchronously instead of waiting on the `/api/organizations` + // fetch. Non-fatal and idempotent (only fills a null username); the NOT + // EXISTS guard avoids colliding with another user's username. Runs for both + // newly created and pre-existing personal orgs so legacy users self-heal on + // their next login. + try { + await sql` + UPDATE "user" + SET username = ${finalResult.slug} + WHERE id = ${user.id} + AND username IS NULL + AND NOT EXISTS ( + SELECT 1 FROM "user" u2 + WHERE u2.username = ${finalResult.slug} AND u2.id <> ${user.id} + ) + `; + } catch (error) { + console.error("[Auth] Failed to set username for personal org:", { + userId: user.id, + slug: finalResult.slug, + error: String(error), + }); + } + + // Provision the $member entity + core identifiers (auth_user_id, email) + // outside the transaction. ensureMemberEntity uses createEntity which + // manages its own transaction and seeds the $member entity type if absent. + // Failures here shouldn't roll back the org creation — the user has a + // valid org, identity rows can be backfilled later. Run this for both newly + // created and pre-existing personal orgs; the helper is idempotent. + if (user.email) { + try { + await provisionMemberAndCoreIdentities(finalResult.organizationId, { + userId: user.id, + email: user.email, + name: user.name, + }); + } catch (error) { + console.error( + "[Auth] Failed to provision $member entity for personal org:", + { + orgId: finalResult.organizationId, + userId: user.id, + error: String(error), + }, + ); + } + } + + return finalResult; }