fix: complete managed Guardian invitation lifecycle

This commit is contained in:
Marc-André Bombeck committed 2026-08-06 03:19:34 +02:00
1 parent 493437207e
commit 389f2a042b
4 files changed
+112 -51

No files matched your search

@@ -24,7 +24,10 @@ const INVITE_LIMIT = 10;
const INVITE_WINDOW_MS = 60 * 60 * 1000;
const guardianInviteSchema = z
.object({ identifier: z.string().trim().min(1).max(320) })
.object({
identifier: z.string().trim().min(1).max(255),
expiresAt: z.iso.datetime({ offset: true }).nullable().optional(),
})
.strict();
/** Invite another Guardian from a cookie-backed, freshly verified session. */
@@ -63,6 +66,9 @@ export const POST = apiHandler(
profileId: id,
guardianId: user.id,
granteeId: invitee.id,
expiresAt: parsed.data.expiresAt
? new Date(parsed.data.expiresAt)
: null,
});
annotate({
action: { name: "managed_profile.guardian.invited" },
@@ -81,6 +87,11 @@ export const POST = apiHandler(
});
}
if (error instanceof ManagedProfileLifecycleError) {
if (error.code === "managed_grantee") {
return apiError("A managed profile cannot be a Guardian", 422, {
errorCode: "managed_profile.guardian.managed_invitee",
});
}
return apiError("Managed profile not found", 404, {
errorCode: "managed_profile.not_found",
});
+1 -1
View File
@@ -12,7 +12,7 @@ export const activeGuardianWhere = (now: Date) => ({
});
export type ManagedProfileLifecycleErrorCode =
"not_found" | "not_managed" | "not_guardian";
"not_found" | "not_managed" | "not_guardian" | "managed_grantee";
export class ManagedProfileLifecycleError extends Error {
constructor(readonly code: ManagedProfileLifecycleErrorCode) {
+46 -9
View File
@@ -52,6 +52,7 @@
* transition computed against the stale row.
*/
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { isP2002 } from "@/lib/prisma-errors";
import { clearActingSessions } from "@/lib/sharing/acting-session";
import {
@@ -469,6 +470,7 @@ export async function inviteManagedProfileGuardian(input: {
profileId: string;
guardianId: string;
granteeId: string;
expiresAt?: Date | null;
}): Promise<AccountGrant> {
return prisma.$transaction(async (tx) => {
return withManagedProfileLock(tx, input.profileId, async (profile) => {
@@ -485,16 +487,30 @@ export async function inviteManagedProfileGuardian(input: {
select: { id: true },
});
if (!guardian) throw new ManagedProfileLifecycleError("not_guardian");
return inviteGrant(
const grantee = await tx.user.findUnique({
where: { id: input.granteeId },
select: { managedProfileAt: true },
});
if (grantee?.managedProfileAt) {
throw new ManagedProfileLifecycleError("managed_grantee");
}
const grant = await inviteGrant(
{
grantorId: profile.id,
granteeId: input.granteeId,
access: "MANAGE",
scope: null,
expiresAt: null,
expiresAt: input.expiresAt ?? null,
},
tx,
);
await auditLog("managed_profile.guardian.invited", {
userId: profile.id,
actorUserId: input.guardianId,
details: { grantId: grant.id },
client: tx,
});
return grant;
});
});
}
@@ -524,28 +540,49 @@ export async function revokeManagedProfileGuardian(input: {
where: {
id: input.grantId,
grantorId: profile.id,
...activeGuardianWhere(new Date()),
access: "MANAGE",
revokedAt: null,
},
select: {
id: true,
granteeId: true,
acceptedAt: true,
expiresAt: true,
},
select: { id: true, granteeId: true },
});
if (!target) throw new ManagedProfileLifecycleError("not_guardian");
if (target.granteeId === input.guardianId) {
throw new ManagedProfileLifecycleError("not_guardian");
}
const activeGuardians = await tx.accountGrant.count({
where: { grantorId: profile.id, ...activeGuardianWhere(new Date()) },
});
if (activeGuardians <= 1) throw new LastManagedGuardianError();
const now = new Date();
const targetIsActive =
target.acceptedAt !== null &&
(target.expiresAt === null || target.expiresAt > now);
if (targetIsActive) {
const activeGuardians = await tx.accountGrant.count({
where: { grantorId: profile.id, ...activeGuardianWhere(now) },
});
if (activeGuardians <= 1) throw new LastManagedGuardianError();
}
await tx.accountGrant.update({
where: { id: target.id },
data: { revokedAt: new Date(), revokedBy: "GRANTOR" },
// `revokedBy` records which grant party withdrew access. The managed
// profile is the grantor; the individual Guardian is durable audit
// attribution below rather than an overloaded enum value.
data: { revokedAt: now, revokedBy: "GRANTOR" },
});
await clearActingSessions(
{ grantorId: profile.id, granteeId: target.granteeId },
tx,
);
await auditLog("managed_profile.guardian.revoked", {
userId: profile.id,
actorUserId: input.guardianId,
details: { grantId: target.id },
client: tx,
});
return tx.accountGrant.findUniqueOrThrow({ where: { id: target.id } });
});
});