fix(sharing): invitations need a fresh proof and end with sign out everywhere

Offering access to the record took a live session alone, and an
invitation outlives the session that sent it: whoever held a stolen
session could invite their own account and keep a way into the record
after the owner signed everything out. POST /api/account/grants now takes
the recent-proof gate at every level (a sign-in or re-proof within five
minutes on the browser, with the second factor on an account that has one;
a step-up elevation on a token, which no shipped client uses here), and
the invite form opens the re-proof dialog and sends again.

Sign out everywhere now withdraws every invitation the account offered
that nobody has accepted. Accepted grants are left standing on purpose:
each is a person the owner chose, often a carer who still needs the
record, and a sign-out button must not quietly cut them off. The response
lists them as grantsKept and the sessions card shows each with a one-click
End access.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 22:34:21 +02:00
1 parent a4d27c618e
commit a294d7f684
22 files changed
+585 -65

No files matched your search

@@ -386,6 +386,65 @@ describe("destroyOtherSessions — everything", () => {
).toBeNull();
});
it("withdraws invitations nobody accepted and leaves accepted grants standing", async () => {
const prisma = getPrismaClient();
const owner = await makeUser("sess-invites-owner");
const carer = await makeUser("sess-invites-carer");
const stranger = await makeUser("sess-invites-stranger");
const other = await makeUser("sess-invites-other");
const accepted = await prisma.accountGrant.create({
data: {
grantorId: owner.id,
granteeId: carer.id,
access: "READ",
acceptedAt: new Date(),
},
});
const pending = await prisma.accountGrant.create({
data: { grantorId: owner.id, granteeId: stranger.id, access: "WRITE" },
});
// Somebody else's invitation to the owner is not the owner's to withdraw.
const received = await prisma.accountGrant.create({
data: { grantorId: other.id, granteeId: owner.id, access: "READ" },
});
const result = await destroyOtherSessions(
owner.id,
{ kind: "accessToken", accessTokenHash: "none" },
{ reach: "everything" },
);
expect(result.pendingInvitesRevoked).toBe(1);
const after = async (id: string) =>
prisma.accountGrant.findUniqueOrThrow({ where: { id } });
expect((await after(pending.id)).revokedAt).not.toBeNull();
expect((await after(pending.id)).revokedBy).toBe("GRANTOR");
expect((await after(accepted.id)).revokedAt).toBeNull();
expect((await after(received.id)).revokedAt).toBeNull();
});
it("sign-ins leaves pending invitations alone", async () => {
const prisma = getPrismaClient();
const owner = await makeUser("sess-invites-signins");
const other = await makeUser("sess-invites-signins-2");
const pending = await prisma.accountGrant.create({
data: { grantorId: owner.id, granteeId: other.id, access: "READ" },
});
const result = await destroyOtherSessions(
owner.id,
{ kind: "accessToken", accessTokenHash: "none" },
{ reach: "sign-ins" },
);
expect(result.pendingInvitesRevoked).toBe(0);
expect(
(
await prisma.accountGrant.findUniqueOrThrow({
where: { id: pending.id },
})
).revokedAt,
).toBeNull();
});
it("sign-ins leaves connections, tokens and share links alone", async () => {
const prisma = getPrismaClient();
const user = await makeUser("sess-signins-only");