merge: todo/mvp0-owner-invariant-enforcement into dev
This commit is contained in:
5
TODO.md
5
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)
|
- [ ] [P1] Locale persistence and switcher base component (cookie/header + UI)
|
||||||
- [x] [P1] Integrate Better Auth core configuration and session wiring
|
- [x] [P1] Integrate Better Auth core configuration and session wiring
|
||||||
- [x] [P1] Bootstrap first-run owner account creation via initial registration flow
|
- [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)
|
- [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)
|
- [~] [P1] Admin registration policy control (allow/deny self-registration for admin panel)
|
||||||
- [x] [P1] First-start onboarding route for initial owner creation (`/welcome`)
|
- [x] [P1] First-start onboarding route for initial owner creation (`/welcome`)
|
||||||
@@ -115,7 +115,7 @@ This file is the single source of truth for roadmap and delivery progress.
|
|||||||
- [ ] [P1] Media refinement for artworks (medium, dimensions, year, framing, availability)
|
- [ ] [P1] Media refinement for artworks (medium, dimensions, year, framing, availability)
|
||||||
- [ ] [P1] Users management (invite, roles, status)
|
- [ ] [P1] Users management (invite, roles, status)
|
||||||
- [ ] [P1] Disable/ban user function and enforcement in auth/session checks
|
- [ ] [P1] Disable/ban user function and enforcement in auth/session checks
|
||||||
- [ ] [P1] Owner/support protection rules in user management actions (cannot delete/demote)
|
- [~] [P1] Owner/support protection rules in user management actions (cannot delete/demote)
|
||||||
- [ ] [P1] Commissions management (request intake, owner, due date, notes)
|
- [ ] [P1] Commissions management (request intake, owner, due date, notes)
|
||||||
- [ ] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done)
|
- [ ] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done)
|
||||||
- [ ] [P1] Header banner management (message, CTA, active window)
|
- [ ] [P1] Header banner management (message, CTA, active window)
|
||||||
@@ -191,6 +191,7 @@ This file is the single source of truth for roadmap and delivery progress.
|
|||||||
- [2026-02-10] Linux Playwright runtime depends on host packages; browser setup may require `playwright install --with-deps`.
|
- [2026-02-10] Linux Playwright runtime depends on host packages; browser setup may require `playwright install --with-deps`.
|
||||||
- [2026-02-10] Next.js 16 deprecates `middleware.ts` convention in favor of `proxy.ts`; admin route guard now lives at `apps/admin/src/proxy.ts`.
|
- [2026-02-10] Next.js 16 deprecates `middleware.ts` convention in favor of `proxy.ts`; admin route guard now lives at `apps/admin/src/proxy.ts`.
|
||||||
- [2026-02-10] `server-only` imports break Bun CLI scripts; shared auth bootstrap code used by scripts must avoid Next-only runtime markers.
|
- [2026-02-10] `server-only` imports break Bun CLI scripts; shared auth bootstrap code used by scripts must avoid Next-only runtime markers.
|
||||||
|
- [2026-02-10] Auth delete-account endpoints now block protected users (support + canonical owner); admin user-management delete/demote guards remain to be implemented.
|
||||||
|
|
||||||
## How We Use This File
|
## How We Use This File
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
|
auth,
|
||||||
authRouteHandlers,
|
authRouteHandlers,
|
||||||
|
canDeleteUserAccount,
|
||||||
canUserSelfRegister,
|
canUserSelfRegister,
|
||||||
ensureSupportUserBootstrap,
|
ensureSupportUserBootstrap,
|
||||||
ensureUserUsername,
|
ensureUserUsername,
|
||||||
@@ -40,6 +42,51 @@ function buildJsonRequest(request: Request, body: Record<string, unknown>): Requ
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDeleteUserAuthPath(pathname: string): boolean {
|
||||||
|
const actionPrefix = "/api/auth/"
|
||||||
|
const actionIndex = pathname.indexOf(actionPrefix)
|
||||||
|
|
||||||
|
if (actionIndex === -1) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionPath = pathname.slice(actionIndex + actionPrefix.length)
|
||||||
|
return actionPath === "delete-user" || actionPath.startsWith("delete-user/")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardProtectedAccountDeletion(request: Request): Promise<Response | null> {
|
||||||
|
const pathname = new URL(request.url).pathname
|
||||||
|
|
||||||
|
if (!isDeleteUserAuthPath(pathname)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth.api
|
||||||
|
.getSession({
|
||||||
|
headers: request.headers,
|
||||||
|
})
|
||||||
|
.catch(() => null)
|
||||||
|
|
||||||
|
const userId = session?.user?.id
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = await canDeleteUserAccount(userId)
|
||||||
|
|
||||||
|
if (allowed) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
message: "This account is protected and cannot be deleted.",
|
||||||
|
},
|
||||||
|
403,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSignInPost(request: Request): Promise<Response> {
|
async function handleSignInPost(request: Request): Promise<Response> {
|
||||||
await ensureSupportUserBootstrap()
|
await ensureSupportUserBootstrap()
|
||||||
|
|
||||||
@@ -136,6 +183,13 @@ async function handleSignUpPost(request: Request): Promise<Response> {
|
|||||||
|
|
||||||
export async function GET(request: Request): Promise<Response> {
|
export async function GET(request: Request): Promise<Response> {
|
||||||
await ensureSupportUserBootstrap()
|
await ensureSupportUserBootstrap()
|
||||||
|
|
||||||
|
const deletionGuardResponse = await guardProtectedAccountDeletion(request)
|
||||||
|
|
||||||
|
if (deletionGuardResponse) {
|
||||||
|
return deletionGuardResponse
|
||||||
|
}
|
||||||
|
|
||||||
return authRouteHandlers.GET(request)
|
return authRouteHandlers.GET(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,20 +205,48 @@ export async function POST(request: Request): Promise<Response> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ensureSupportUserBootstrap()
|
await ensureSupportUserBootstrap()
|
||||||
|
|
||||||
|
const deletionGuardResponse = await guardProtectedAccountDeletion(request)
|
||||||
|
|
||||||
|
if (deletionGuardResponse) {
|
||||||
|
return deletionGuardResponse
|
||||||
|
}
|
||||||
|
|
||||||
return authRouteHandlers.POST(request)
|
return authRouteHandlers.POST(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(request: Request): Promise<Response> {
|
export async function PATCH(request: Request): Promise<Response> {
|
||||||
await ensureSupportUserBootstrap()
|
await ensureSupportUserBootstrap()
|
||||||
|
|
||||||
|
const deletionGuardResponse = await guardProtectedAccountDeletion(request)
|
||||||
|
|
||||||
|
if (deletionGuardResponse) {
|
||||||
|
return deletionGuardResponse
|
||||||
|
}
|
||||||
|
|
||||||
return authRouteHandlers.PATCH(request)
|
return authRouteHandlers.PATCH(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: Request): Promise<Response> {
|
export async function PUT(request: Request): Promise<Response> {
|
||||||
await ensureSupportUserBootstrap()
|
await ensureSupportUserBootstrap()
|
||||||
|
|
||||||
|
const deletionGuardResponse = await guardProtectedAccountDeletion(request)
|
||||||
|
|
||||||
|
if (deletionGuardResponse) {
|
||||||
|
return deletionGuardResponse
|
||||||
|
}
|
||||||
|
|
||||||
return authRouteHandlers.PUT(request)
|
return authRouteHandlers.PUT(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: Request): Promise<Response> {
|
export async function DELETE(request: Request): Promise<Response> {
|
||||||
await ensureSupportUserBootstrap()
|
await ensureSupportUserBootstrap()
|
||||||
|
|
||||||
|
const deletionGuardResponse = await guardProtectedAccountDeletion(request)
|
||||||
|
|
||||||
|
if (deletionGuardResponse) {
|
||||||
|
return deletionGuardResponse
|
||||||
|
}
|
||||||
|
|
||||||
return authRouteHandlers.DELETE(request)
|
return authRouteHandlers.DELETE(request)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -359,7 +359,10 @@ export async function ensureSupportUserBootstrap(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
supportBootstrapPromise = bootstrapSystemUsers()
|
supportBootstrapPromise = (async () => {
|
||||||
|
await bootstrapSystemUsers()
|
||||||
|
await enforceOwnerInvariant()
|
||||||
|
})()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await supportBootstrapPromise
|
await supportBootstrapPromise
|
||||||
@@ -369,8 +372,116 @@ export async function ensureSupportUserBootstrap(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function promoteFirstRegisteredUserToOwner(userId: string): Promise<boolean> {
|
type OwnerInvariantState = {
|
||||||
|
ownerId: string | null
|
||||||
|
ownerCount: number
|
||||||
|
repaired: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enforceOwnerInvariant(): Promise<OwnerInvariantState> {
|
||||||
return db.$transaction(async (tx) => {
|
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 canDeleteUserAccount(userId: string): Promise<boolean> {
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { role: true, isProtected: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected/system users (support + canonical owner) are never deletable
|
||||||
|
// through self-service endpoints.
|
||||||
|
if (user.isProtected) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role !== "owner") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defensive fallback for drifted data; normal flow should already keep one owner.
|
||||||
|
const ownerCount = await db.user.count({
|
||||||
|
where: { role: "owner" },
|
||||||
|
})
|
||||||
|
|
||||||
|
return ownerCount > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function promoteFirstRegisteredUserToOwner(userId: string): Promise<boolean> {
|
||||||
|
const promoted = await db.$transaction(async (tx) => {
|
||||||
const existingOwner = await tx.user.findFirst({
|
const existingOwner = await tx.user.findFirst({
|
||||||
where: { role: "owner" },
|
where: { role: "owner" },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -393,6 +504,12 @@ export async function promoteFirstRegisteredUserToOwner(userId: string): Promise
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (promoted) {
|
||||||
|
await enforceOwnerInvariant()
|
||||||
|
}
|
||||||
|
|
||||||
|
return promoted
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveRoleFromAuthSession(session: AuthSession | null | undefined): Role | null {
|
export function resolveRoleFromAuthSession(session: AuthSession | null | undefined): Role | null {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ Implemented in MVP0:
|
|||||||
- Support fallback sign-in page: `/support/<CMS_SUPPORT_LOGIN_KEY>`
|
- Support fallback sign-in page: `/support/<CMS_SUPPORT_LOGIN_KEY>`
|
||||||
- Prisma auth models (`user`, `session`, `account`, `verification`)
|
- Prisma auth models (`user`, `session`, `account`, `verification`)
|
||||||
- First registration creates owner; subsequent registrations are disabled
|
- First registration creates owner; subsequent registrations are disabled
|
||||||
|
- Owner invariant reconciliation is enforced in auth bootstrap and owner promotion flow
|
||||||
|
- Protected accounts (support + canonical owner) are blocked from delete-account auth endpoints
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
@@ -38,5 +40,5 @@ Optional:
|
|||||||
- Support user bootstrap is available via `bun run auth:seed:support`.
|
- Support user bootstrap is available via `bun run auth:seed:support`.
|
||||||
- Root `bun run db:seed` runs DB seed and support-user seed.
|
- 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.
|
- `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/support checks for future admin user-management mutations remain tracked in `TODO.md`.
|
||||||
- Email verification and forgot/reset password pipelines are tracked for MVP2.
|
- Email verification and forgot/reset password pipelines are tracked for MVP2.
|
||||||
|
|||||||
Reference in New Issue
Block a user