From 29a6e38ff3b725e4232736c4b5b007a2989acb82 Mon Sep 17 00:00:00 2001 From: Citali Date: Tue, 10 Feb 2026 18:43:06 +0100 Subject: [PATCH] feat(auth): enforce single-owner invariant in bootstrap flow --- TODO.md | 2 +- apps/admin/src/lib/auth/server.ts | 93 ++++++++++++++++++++++- docs/product-engineering/auth-baseline.md | 3 +- 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index 5c72fe9..b644724 100644 --- a/TODO.md +++ b/TODO.md @@ -26,7 +26,7 @@ This file is the single source of truth for roadmap and delivery progress. - [ ] [P1] Locale persistence and switcher base component (cookie/header + UI) - [x] [P1] Integrate Better Auth core configuration and session wiring - [x] [P1] Bootstrap first-run owner account creation via initial registration flow -- [ ] [P1] Enforce invariant: exactly one owner user must always exist +- [x] [P1] Enforce invariant: exactly one owner user must always exist - [x] [P1] Create hidden technical support user by default (non-demotable, non-deletable) - [~] [P1] Admin registration policy control (allow/deny self-registration for admin panel) - [x] [P1] First-start onboarding route for initial owner creation (`/welcome`) diff --git a/apps/admin/src/lib/auth/server.ts b/apps/admin/src/lib/auth/server.ts index ac583db..3981f2c 100644 --- a/apps/admin/src/lib/auth/server.ts +++ b/apps/admin/src/lib/auth/server.ts @@ -359,7 +359,10 @@ export async function ensureSupportUserBootstrap(): Promise { return } - supportBootstrapPromise = bootstrapSystemUsers() + supportBootstrapPromise = (async () => { + await bootstrapSystemUsers() + await enforceOwnerInvariant() + })() try { await supportBootstrapPromise @@ -369,8 +372,88 @@ export async function ensureSupportUserBootstrap(): Promise { } } -export async function promoteFirstRegisteredUserToOwner(userId: string): Promise { +type OwnerInvariantState = { + ownerId: string | null + ownerCount: number + repaired: boolean +} + +export async function enforceOwnerInvariant(): Promise { return db.$transaction(async (tx) => { + const owners = await tx.user.findMany({ + where: { role: "owner" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { id: true, isProtected: true, isBanned: true }, + }) + + if (owners.length === 0) { + const candidate = await tx.user.findFirst({ + where: { + role: { + not: "support", + }, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { id: true }, + }) + + if (!candidate) { + return { + ownerId: null, + ownerCount: 0, + repaired: false, + } + } + + await tx.user.update({ + where: { id: candidate.id }, + data: { + role: "owner", + isProtected: true, + isBanned: false, + }, + }) + + return { + ownerId: candidate.id, + ownerCount: 1, + repaired: true, + } + } + + const canonicalOwner = owners[0] + const extraOwnerIds = owners.slice(1).map((owner) => owner.id) + + if (extraOwnerIds.length > 0) { + await tx.user.updateMany({ + where: { id: { in: extraOwnerIds } }, + data: { + role: "admin", + isProtected: false, + }, + }) + } + + if (!canonicalOwner.isProtected || canonicalOwner.isBanned) { + await tx.user.update({ + where: { id: canonicalOwner.id }, + data: { + isProtected: true, + isBanned: false, + }, + }) + } + + return { + ownerId: canonicalOwner.id, + ownerCount: 1, + repaired: extraOwnerIds.length > 0 || !canonicalOwner.isProtected || canonicalOwner.isBanned, + } + }) +} + +export async function promoteFirstRegisteredUserToOwner(userId: string): Promise { + const promoted = await db.$transaction(async (tx) => { const existingOwner = await tx.user.findFirst({ where: { role: "owner" }, select: { id: true }, @@ -393,6 +476,12 @@ export async function promoteFirstRegisteredUserToOwner(userId: string): Promise return true }) + + if (promoted) { + await enforceOwnerInvariant() + } + + return promoted } export function resolveRoleFromAuthSession(session: AuthSession | null | undefined): Role | null { diff --git a/docs/product-engineering/auth-baseline.md b/docs/product-engineering/auth-baseline.md index 131e5f4..0c539a4 100644 --- a/docs/product-engineering/auth-baseline.md +++ b/docs/product-engineering/auth-baseline.md @@ -12,6 +12,7 @@ Implemented in MVP0: - Support fallback sign-in page: `/support/` - Prisma auth models (`user`, `session`, `account`, `verification`) - First registration creates owner; subsequent registrations are disabled +- Owner invariant reconciliation is enforced in auth bootstrap and owner promotion flow ## Environment @@ -38,5 +39,5 @@ Optional: - Support user bootstrap is available via `bun run auth:seed:support`. - Root `bun run db:seed` runs DB seed and support-user seed. - `CMS_ADMIN_SELF_REGISTRATION_ENABLED` is temporary until admin settings UI manages this policy. -- Owner invariant hardening for all future user-management mutations remains tracked in `TODO.md`. +- Owner invariant checks for future user-management mutations remain tracked in `TODO.md`. - Email verification and forgot/reset password pipelines are tracked for MVP2.