fix(handover): keep a claimed handover when its Guardian deletes their account

created_by_id cascaded, so a Guardian who deleted their own account took
every handover they had issued with them, including a claimed one whose
decision the new owner had not made yet. The claim had already applied
the proposal, possibly MANAGE for a co-Guardian, and the owner lost the
one place that asks them to confirm it.

The column is nullable now and cleared on delete (migration 0390). An
unclaimed link without a creator is refused like any link whose creator
lost access. A decision that names a Guardian whose account is gone is
refused with 422 instead of failing on a grant for a missing account.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 22:37:56 +02:00
1 parent c7cee9c8a7
commit 98183700ce
4 files changed
+94 -6

No files matched your search

@@ -0,0 +1,14 @@
-- v1.42 (#959): a handover outlives the Guardian who minted it.
--
-- `created_by_id` cascaded, so a Guardian deleting their own account deleted
-- every handover they had issued, including a claimed one whose decision the
-- new owner had not made yet. The claim had already applied the proposal
-- (possibly MANAGE for a co-Guardian), and the owner lost the one place that
-- asks them to confirm it. The creator is now cleared instead; an unclaimed
-- link without a creator is refused by the claim like any link whose creator
-- lost access.
ALTER TABLE "managed_profile_handovers" ALTER COLUMN "created_by_id" DROP NOT NULL;
ALTER TABLE "managed_profile_handovers" DROP CONSTRAINT "managed_profile_handovers_created_by_id_fkey";
ALTER TABLE "managed_profile_handovers" ADD CONSTRAINT "managed_profile_handovers_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+7 -4
View File
@@ -3927,8 +3927,11 @@ model ManagedProfileHandover {
id String @id @default(cuid())
/// The managed profile (`User` row with `managedProfileAt` set).
profileId String @map("profile_id")
/// The guardian who minted the link.
createdById String @map("created_by_id")
/// The guardian who minted the link. Cleared when that account is deleted
/// (0390): a claimed handover keeps the new owner's pending decision, and
/// an unclaimed link without a creator is refused like any link whose
/// creator lost access.
createdById String? @map("created_by_id")
/// HMAC-SHA256 of the token, unique; the claim and its preview look the
/// link up by it (`src/lib/managed-profiles/handover.ts`).
tokenHash String @unique @map("token_hash")
@@ -3947,8 +3950,8 @@ model ManagedProfileHandover {
/// creator lost access.
revokedAt DateTime? @map("revoked_at")
profile User @relation("HandoverProfile", fields: [profileId], references: [id], onDelete: Cascade)
createdBy User @relation("HandoverCreator", fields: [createdById], references: [id], onDelete: Cascade)
profile User @relation("HandoverProfile", fields: [profileId], references: [id], onDelete: Cascade)
createdBy User? @relation("HandoverCreator", fields: [createdById], references: [id], onDelete: SetNull)
@@index([profileId])
// `managed_profile_handovers_open_idx` UNIQUE (profile_id) WHERE used_at IS
+31 -2
View File
@@ -199,9 +199,12 @@ function proposalFor(state: HandoverState, grantId: string): HandoverAccess {
async function isActiveGuardian(
db: Pick<Transaction, "accountGrant">,
profileId: string,
guardianId: string,
guardianId: string | null,
now: Date,
): Promise<boolean> {
// A link whose creator's account is gone (`created_by_id` cleared) has no
// Guardian behind it.
if (guardianId === null) return false;
const grant = await db.accountGrant.findFirst({
where: {
grantorId: profileId,
@@ -793,7 +796,23 @@ export async function decideHandover(input: {
throw new HandoverError("no_pending");
}
const claim = state.claim;
const known = new Set(claim.guardians.map((e) => e.grantId));
// A former Guardian whose account is gone has nothing left to decide:
// the pending read leaves them out, and a decision that names them
// anyway is refused like one about a stranger, rather than failing on
// a grant written for an account that no longer exists.
const present = new Set(
(
await tx.user.findMany({
where: { id: { in: claim.guardians.map((e) => e.guardianId) } },
select: { id: true },
})
).map((u) => u.id),
);
const known = new Set(
claim.guardians
.filter((e) => present.has(e.guardianId))
.map((e) => e.grantId),
);
if (input.decisions.some((d) => !known.has(d.grantId))) {
throw new HandoverError("unknown_guardian");
}
@@ -807,6 +826,16 @@ export async function decideHandover(input: {
}[] = [];
const guardians: StoredClaimEntry[] = [];
for (const entry of claim.guardians) {
if (!present.has(entry.guardianId)) {
guardians.push(entry);
outcome.push({
grantId: entry.grantId,
access: entry.applied,
changed: false,
skipped: true,
});
continue;
}
const target =
input.decisions.find((d) => d.grantId === entry.grantId)?.access ??
entry.applied;
@@ -990,6 +990,48 @@ describe("the new owner's decision", () => {
expect(response.status).toBe(422);
});
it("keeps the decision waiting when the issuing Guardian deletes their account", async () => {
const h = await claimedHousehold((x) => [
{ grantId: x.creatorGrant.id, proposal: "end" },
{ grantId: x.secondGrant.id, proposal: "manage" },
]);
const prisma = getPrismaClient();
await prisma.user.delete({ where: { id: h.creator.id } });
const handover = await prisma.managedProfileHandover.findFirstOrThrow({
where: { profileId: h.profile.id },
});
expect(handover.createdById).toBeNull();
const pending = (await (await readDecision()).json()).data.pending;
expect(pending).not.toBeNull();
expect(pending.guardians).toEqual([
expect.objectContaining({
grantId: h.secondGrant.id,
proposal: "manage",
current: "manage",
decidable: true,
}),
]);
expect(
(await decide([{ grantId: h.secondGrant.id, access: "read" }])).status,
).toBe(200);
});
it("refuses a decision about a Guardian whose account is gone", async () => {
const h = await claimedHousehold((x) => [
{ grantId: x.secondGrant.id, proposal: "end" },
]);
await getPrismaClient().user.delete({ where: { id: h.second.id } });
const response = await decide([
{ grantId: h.secondGrant.id, access: "manage" },
]);
expect(response.status).toBe(422);
expect((await response.json()).meta.errorCode).toBe(
"managed_profile.handover.unknown_guardian",
);
});
it("answers no pending decision to an account that never was a profile", async () => {
const someone = await person("someone");
signIn(someone);