mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
POST /api/auth/claim/sso checks the handover link and the username and seals the link id, the username and a per-start binding into an hl_claim cookie. GET /api/auth/oidc/login?intent=claim carries the binding into the encrypted OIDC state, and the callback binds the provider subject to the profile in the claim transaction, refusing a subject or verified email another account holds, a link that is no longer usable, and a live session. The handover status and the claim preview name the redemption methods; a single-sign-on-only server hands over through the identity provider, and only the password claim stays refused there.
987 lines
33 KiB
TypeScript
987 lines
33 KiB
TypeScript
/**
|
|
* v1.42 (#959) — handing a managed profile over to the person it describes.
|
|
*
|
|
* A managed profile is a `User` row nobody signs into, kept by its Guardians.
|
|
* The handover turns it into that person's own account. No data moves: every
|
|
* reading already hangs off the profile's own id. What changes is who can sign
|
|
* in to it and who else keeps access.
|
|
*
|
|
* The flow, in the order it happens:
|
|
*
|
|
* 1. A Guardian, with a fresh second factor, mints a one-time link and
|
|
* proposes a level per Guardian (`handover-access.ts`). Only the HMAC of
|
|
* the token is stored; at most one link per profile is open, and minting
|
|
* a new one withdraws the old.
|
|
* 2. The person opens the link, sees a minimal preview (the profile's name,
|
|
* the link's expiry, each Guardian's proposed access — no health data),
|
|
* and chooses a username, an email and a password.
|
|
* 3. The claim runs as ONE transaction under the managed-profile lock: the
|
|
* token is consumed, the credentials are set, the marker is cleared,
|
|
* every pending invitation the record has offered is withdrawn, each
|
|
* Guardian's access is set to its proposal, the disclaimer, onboarding
|
|
* and AI consent are reset so the new owner gives them personally, and
|
|
* every step is audited. A failure anywhere leaves the profile managed
|
|
* and the link unused.
|
|
* 4. On first sign-in the new owner sees each Guardian's access and decides
|
|
* finally. Until they do, the proposal is what holds.
|
|
*
|
|
* Every refusal on the anonymous side is the same `invalid`, so the preview
|
|
* and the claim cannot be used to learn whether a token, a profile or a
|
|
* Guardian exists.
|
|
*/
|
|
import { prisma } from "@/lib/db";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import {
|
|
generateHandoverToken,
|
|
handoverHashMatches,
|
|
hashHandoverToken,
|
|
looksLikeHandoverToken,
|
|
} from "@/lib/auth/handover-token";
|
|
import { withdrawConsentInTransaction } from "@/lib/consent/withdrawal";
|
|
import { consentKindEnum } from "@/lib/validations/consent";
|
|
import {
|
|
activeGuardianWhere,
|
|
clearManagedProfileMarker,
|
|
withManagedProfileLock,
|
|
} from "@/lib/managed-profiles/lifecycle";
|
|
import {
|
|
DEFAULT_HANDOVER_ACCESS,
|
|
HANDOVER_ACCESS_LEVELS,
|
|
handoverAccessOf,
|
|
type HandoverAccess,
|
|
} from "@/lib/managed-profiles/handover-access";
|
|
import {
|
|
isGrantActive,
|
|
settleHandoverGuardianGrant,
|
|
withdrawPendingGrantsOfRecord,
|
|
} from "@/lib/sharing/grants";
|
|
import { isP2002 } from "@/lib/prisma-errors";
|
|
import type { Prisma } from "@/generated/prisma/client";
|
|
|
|
type Transaction = Prisma.TransactionClient;
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
export type HandoverErrorCode =
|
|
/** No such managed profile, or the caller is not one of its Guardians. */
|
|
| "not_found"
|
|
/** A proposal names a grant that is not an active Guardian of the profile. */
|
|
| "unknown_guardian"
|
|
/** Anonymous side: no usable link behind the token. Every cause. */
|
|
| "invalid"
|
|
/** The username or email is already taken. */
|
|
| "taken"
|
|
/** The new owner has no handover decision waiting. */
|
|
| "no_pending";
|
|
|
|
export class HandoverError extends Error {
|
|
constructor(readonly code: HandoverErrorCode) {
|
|
super(`managed profile handover refused: ${code}`);
|
|
this.name = "HandoverError";
|
|
}
|
|
}
|
|
|
|
// ── Stored state ────────────────────────────────────────────────────────────
|
|
|
|
/** One Guardian's proposal, as the issuing Guardian recorded it. */
|
|
interface StoredProposal {
|
|
/** The Guardian's MANAGE grant when the link was minted. */
|
|
grantId: string;
|
|
guardianId: string;
|
|
proposal: HandoverAccess;
|
|
}
|
|
|
|
/** One Guardian's access across the claim and the owner's decision. */
|
|
interface StoredClaimEntry {
|
|
/** The Guardian's MANAGE grant at the moment of the claim. */
|
|
grantId: string;
|
|
guardianId: string;
|
|
proposal: HandoverAccess;
|
|
/** What the claim set. */
|
|
applied: HandoverAccess;
|
|
/** The live grant the handover last wrote or kept, or null for `end`. */
|
|
currentGrantId: string | null;
|
|
/** The new owner's final choice, once made. */
|
|
decided?: HandoverAccess;
|
|
}
|
|
|
|
/**
|
|
* Everything `proposals_json` holds. Versioned, because the column outlives
|
|
* the code that wrote it: a row from a later shape that this code cannot read
|
|
* is treated as no handover at all, never guessed at.
|
|
*/
|
|
interface HandoverState {
|
|
version: 1;
|
|
proposals: StoredProposal[];
|
|
claim?: {
|
|
claimedAt: string;
|
|
guardians: StoredClaimEntry[];
|
|
decidedAt: string | null;
|
|
};
|
|
}
|
|
|
|
function isAccess(value: unknown): value is HandoverAccess {
|
|
return (
|
|
typeof value === "string" &&
|
|
(HANDOVER_ACCESS_LEVELS as readonly string[]).includes(value)
|
|
);
|
|
}
|
|
|
|
function isString(value: unknown): value is string {
|
|
return typeof value === "string" && value.length > 0;
|
|
}
|
|
|
|
/** Read the column, refusing anything it does not fully understand. */
|
|
export function parseHandoverState(value: unknown): HandoverState | null {
|
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
return null;
|
|
}
|
|
const raw = value as Record<string, unknown>;
|
|
if (raw.version !== 1 || !Array.isArray(raw.proposals)) return null;
|
|
|
|
const proposals: StoredProposal[] = [];
|
|
for (const entry of raw.proposals) {
|
|
const e = entry as Record<string, unknown> | null;
|
|
if (!e || !isString(e.grantId) || !isString(e.guardianId)) return null;
|
|
if (!isAccess(e.proposal)) return null;
|
|
proposals.push({
|
|
grantId: e.grantId,
|
|
guardianId: e.guardianId,
|
|
proposal: e.proposal,
|
|
});
|
|
}
|
|
|
|
if (raw.claim === undefined) return { version: 1, proposals };
|
|
const claim = raw.claim as Record<string, unknown> | null;
|
|
if (!claim || !isString(claim.claimedAt) || !Array.isArray(claim.guardians)) {
|
|
return null;
|
|
}
|
|
if (claim.decidedAt !== null && !isString(claim.decidedAt)) return null;
|
|
const guardians: StoredClaimEntry[] = [];
|
|
for (const entry of claim.guardians) {
|
|
const e = entry as Record<string, unknown> | null;
|
|
if (!e || !isString(e.grantId) || !isString(e.guardianId)) return null;
|
|
if (!isAccess(e.proposal) || !isAccess(e.applied)) return null;
|
|
if (e.currentGrantId !== null && !isString(e.currentGrantId)) return null;
|
|
if (e.decided !== undefined && !isAccess(e.decided)) return null;
|
|
guardians.push({
|
|
grantId: e.grantId,
|
|
guardianId: e.guardianId,
|
|
proposal: e.proposal,
|
|
applied: e.applied,
|
|
currentGrantId: e.currentGrantId,
|
|
...(e.decided !== undefined ? { decided: e.decided } : {}),
|
|
});
|
|
}
|
|
return {
|
|
version: 1,
|
|
proposals,
|
|
claim: {
|
|
claimedAt: claim.claimedAt,
|
|
guardians,
|
|
decidedAt: claim.decidedAt,
|
|
},
|
|
};
|
|
}
|
|
|
|
function toJson(state: HandoverState): Prisma.InputJsonValue {
|
|
return state as unknown as Prisma.InputJsonValue;
|
|
}
|
|
|
|
/** The proposal for a Guardian grant, or the default when none was named. */
|
|
function proposalFor(state: HandoverState, grantId: string): HandoverAccess {
|
|
return (
|
|
state.proposals.find((entry) => entry.grantId === grantId)?.proposal ??
|
|
DEFAULT_HANDOVER_ACCESS
|
|
);
|
|
}
|
|
|
|
async function isActiveGuardian(
|
|
db: Pick<Transaction, "accountGrant">,
|
|
profileId: 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,
|
|
granteeId: guardianId,
|
|
...activeGuardianWhere(now),
|
|
},
|
|
select: { id: true },
|
|
});
|
|
return grant !== null;
|
|
}
|
|
|
|
function nameOf(user: { displayName: string | null; username: string }) {
|
|
return user.displayName?.trim() || user.username;
|
|
}
|
|
|
|
// ── Guardian side ───────────────────────────────────────────────────────────
|
|
|
|
export interface CreatedHandover {
|
|
id: string;
|
|
/** The raw token. Returned once, here, and never stored. */
|
|
token: string;
|
|
expiresAt: Date;
|
|
}
|
|
|
|
/**
|
|
* Mint the one-time link. Any active Guardian may, not only the profile's
|
|
* creator: Guardians of one record hold the same standing.
|
|
*
|
|
* One proposal per active Guardian is recorded. A Guardian the caller left
|
|
* out is proposed {@link DEFAULT_HANDOVER_ACCESS}; a proposal naming a grant
|
|
* that is not an active Guardian of this profile is refused rather than
|
|
* dropped, because it means the form and the roster disagree.
|
|
*/
|
|
export async function createHandover(input: {
|
|
profileId: string;
|
|
guardianId: string;
|
|
expiresInDays: number;
|
|
proposals: { grantId: string; proposal: HandoverAccess }[];
|
|
now?: Date;
|
|
}): Promise<CreatedHandover> {
|
|
const now = input.now ?? new Date();
|
|
return prisma.$transaction((tx) =>
|
|
withManagedProfileLock(tx, input.profileId, async (profile) => {
|
|
if (!profile?.managedProfileAt) throw new HandoverError("not_found");
|
|
|
|
const guardians = await tx.accountGrant.findMany({
|
|
where: { grantorId: profile.id, ...activeGuardianWhere(now) },
|
|
select: { id: true, granteeId: true },
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
if (!guardians.some((g) => g.granteeId === input.guardianId)) {
|
|
throw new HandoverError("not_found");
|
|
}
|
|
const known = new Set(guardians.map((g) => g.id));
|
|
if (input.proposals.some((p) => !known.has(p.grantId))) {
|
|
throw new HandoverError("unknown_guardian");
|
|
}
|
|
|
|
const proposals: StoredProposal[] = guardians.map((g) => ({
|
|
grantId: g.id,
|
|
guardianId: g.granteeId,
|
|
proposal:
|
|
input.proposals.find((p) => p.grantId === g.id)?.proposal ??
|
|
DEFAULT_HANDOVER_ACCESS,
|
|
}));
|
|
|
|
// At most one open link per profile: the partial unique index enforces
|
|
// it, and this is what keeps a second mint from colliding with it.
|
|
const replaced = await tx.managedProfileHandover.updateMany({
|
|
where: { profileId: profile.id, usedAt: null, revokedAt: null },
|
|
data: { revokedAt: now },
|
|
});
|
|
|
|
const token = generateHandoverToken();
|
|
const expiresAt = new Date(now.getTime() + input.expiresInDays * DAY_MS);
|
|
const row = await tx.managedProfileHandover.create({
|
|
data: {
|
|
profileId: profile.id,
|
|
createdById: input.guardianId,
|
|
tokenHash: hashHandoverToken(token),
|
|
proposalsJson: toJson({ version: 1, proposals }),
|
|
expiresAt,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
// Filed under the RECORD with the Guardian as the actor, like every
|
|
// other act on a managed profile. No token, no names.
|
|
await auditLog("managed_profile.handover.created", {
|
|
userId: profile.id,
|
|
actorUserId: input.guardianId,
|
|
details: {
|
|
handoverId: row.id,
|
|
expiresAt: expiresAt.toISOString(),
|
|
replaced: replaced.count,
|
|
proposals: proposals.map((p) => ({
|
|
grantId: p.grantId,
|
|
proposal: p.proposal,
|
|
})),
|
|
},
|
|
client: tx,
|
|
});
|
|
|
|
return { id: row.id, token, expiresAt };
|
|
}),
|
|
);
|
|
}
|
|
|
|
export interface HandoverStatus {
|
|
open: {
|
|
id: string;
|
|
createdAt: Date;
|
|
expiresAt: Date;
|
|
createdByCaller: boolean;
|
|
} | null;
|
|
}
|
|
|
|
/**
|
|
* The profile's open link, for a Guardian's panel. Never the link itself:
|
|
* only its hash exists. `null` when the caller may not see the profile.
|
|
*/
|
|
export async function readHandoverStatus(input: {
|
|
profileId: string;
|
|
guardianId: string;
|
|
now?: Date;
|
|
}): Promise<HandoverStatus | null> {
|
|
const now = input.now ?? new Date();
|
|
const profile = await prisma.user.findUnique({
|
|
where: { id: input.profileId },
|
|
select: { managedProfileAt: true },
|
|
});
|
|
if (!profile?.managedProfileAt) return null;
|
|
if (
|
|
!(await isActiveGuardian(prisma, input.profileId, input.guardianId, now))
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const open = await prisma.managedProfileHandover.findFirst({
|
|
where: {
|
|
profileId: input.profileId,
|
|
usedAt: null,
|
|
revokedAt: null,
|
|
expiresAt: { gt: now },
|
|
},
|
|
select: { id: true, createdAt: true, expiresAt: true, createdById: true },
|
|
});
|
|
// A link whose creator has since lost access cannot be claimed; showing it
|
|
// as open would promise something the claim refuses.
|
|
if (
|
|
!open ||
|
|
!(await isActiveGuardian(prisma, input.profileId, open.createdById, now))
|
|
) {
|
|
return { open: null };
|
|
}
|
|
return {
|
|
open: {
|
|
id: open.id,
|
|
createdAt: open.createdAt,
|
|
expiresAt: open.expiresAt,
|
|
createdByCaller: open.createdById === input.guardianId,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Withdraw the open link. Any active Guardian may, with no step-up: taking a
|
|
* link back must never be harder than handing it out.
|
|
*/
|
|
export async function revokeHandover(input: {
|
|
profileId: string;
|
|
guardianId: string;
|
|
now?: Date;
|
|
}): Promise<{ revoked: boolean }> {
|
|
const now = input.now ?? new Date();
|
|
return prisma.$transaction((tx) =>
|
|
withManagedProfileLock(tx, input.profileId, async (profile) => {
|
|
if (!profile?.managedProfileAt) throw new HandoverError("not_found");
|
|
if (!(await isActiveGuardian(tx, profile.id, input.guardianId, now))) {
|
|
throw new HandoverError("not_found");
|
|
}
|
|
const { count } = await tx.managedProfileHandover.updateMany({
|
|
where: { profileId: profile.id, usedAt: null, revokedAt: null },
|
|
data: { revokedAt: now },
|
|
});
|
|
if (count > 0) {
|
|
await auditLog("managed_profile.handover.revoked", {
|
|
userId: profile.id,
|
|
actorUserId: input.guardianId,
|
|
details: { count },
|
|
client: tx,
|
|
});
|
|
}
|
|
return { revoked: count > 0 };
|
|
}),
|
|
);
|
|
}
|
|
|
|
// ── Anonymous side ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* The stored row behind a raw token, by keyed-hash lookup, or null. A token
|
|
* that fails the shape gate costs no HMAC and no query.
|
|
*/
|
|
async function findHandoverByToken(
|
|
db: Pick<Transaction, "managedProfileHandover">,
|
|
rawToken: string,
|
|
) {
|
|
if (!looksLikeHandoverToken(rawToken)) return null;
|
|
const tokenHash = hashHandoverToken(rawToken);
|
|
const row = await db.managedProfileHandover.findUnique({
|
|
where: { tokenHash },
|
|
});
|
|
if (!row || !handoverHashMatches(row.tokenHash, tokenHash)) return null;
|
|
return row;
|
|
}
|
|
|
|
function isOpen(
|
|
row: { usedAt: Date | null; revokedAt: Date | null; expiresAt: Date },
|
|
now: Date,
|
|
): boolean {
|
|
return row.usedAt === null && row.revokedAt === null && row.expiresAt > now;
|
|
}
|
|
|
|
export interface HandoverPreview {
|
|
displayName: string | null;
|
|
expiresAt: Date;
|
|
/**
|
|
* Display names only: the holder of the link has proved nothing beyond
|
|
* holding it, so no grant id and no login name (a Guardian without a
|
|
* display name is null, and the page says "a guardian").
|
|
*/
|
|
guardians: {
|
|
displayName: string | null;
|
|
proposal: HandoverAccess;
|
|
}[];
|
|
}
|
|
|
|
/**
|
|
* The open link behind a stored row, or null when it can no longer be
|
|
* claimed: used, withdrawn, expired, a state this code cannot read, a profile
|
|
* no longer managed, or a creator who lost access. The one definition of
|
|
* "usable" the preview, the single-sign-on start and the callback share.
|
|
*/
|
|
async function usableHandover(
|
|
row: Awaited<ReturnType<typeof findHandoverByToken>>,
|
|
now: Date,
|
|
): Promise<{
|
|
row: NonNullable<Awaited<ReturnType<typeof findHandoverByToken>>>;
|
|
state: HandoverState;
|
|
displayName: string | null;
|
|
} | null> {
|
|
if (!row || !isOpen(row, now)) return null;
|
|
const state = parseHandoverState(row.proposalsJson);
|
|
if (!state) return null;
|
|
|
|
const profile = await prisma.user.findUnique({
|
|
where: { id: row.profileId },
|
|
select: { displayName: true, managedProfileAt: true },
|
|
});
|
|
if (!profile?.managedProfileAt) return null;
|
|
if (!(await isActiveGuardian(prisma, row.profileId, row.createdById, now))) {
|
|
return null;
|
|
}
|
|
return { row, state, displayName: profile.displayName };
|
|
}
|
|
|
|
/**
|
|
* What a link would hand over, or null for every kind of unusable link.
|
|
*
|
|
* Deliberately minimal: the profile's name, the expiry, and who keeps which
|
|
* access. No health data, no date of birth, no usernames and no grant ids —
|
|
* the person holding the link has not proved anything yet beyond holding it.
|
|
*/
|
|
export async function previewHandover(
|
|
rawToken: string,
|
|
now: Date = new Date(),
|
|
): Promise<HandoverPreview | null> {
|
|
const usable = await usableHandover(
|
|
await findHandoverByToken(prisma, rawToken),
|
|
now,
|
|
);
|
|
if (!usable) return null;
|
|
const { row, state } = usable;
|
|
|
|
const guardians = await prisma.accountGrant.findMany({
|
|
where: { grantorId: row.profileId, ...activeGuardianWhere(now) },
|
|
select: {
|
|
id: true,
|
|
grantee: { select: { displayName: true } },
|
|
},
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
return {
|
|
displayName: usable.displayName,
|
|
expiresAt: row.expiresAt,
|
|
guardians: guardians.map((g) => ({
|
|
displayName: g.grantee.displayName?.trim() || null,
|
|
proposal: proposalFor(state, g.id),
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* v1.43 (#959) — the usable link behind a raw token, or null. The
|
|
* single-sign-on start seals its id, never the token, into the claim cookie.
|
|
*/
|
|
export async function usableHandoverForToken(
|
|
rawToken: string,
|
|
now: Date = new Date(),
|
|
): Promise<{ handoverId: string; profileId: string } | null> {
|
|
const usable = await usableHandover(
|
|
await findHandoverByToken(prisma, rawToken),
|
|
now,
|
|
);
|
|
return usable
|
|
? { handoverId: usable.row.id, profileId: usable.row.profileId }
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* v1.43 (#959) — whether the link with this id can still be claimed. The
|
|
* single-sign-on legs re-ask on every step, so a link withdrawn or replaced
|
|
* while the person is at the identity provider is refused before any account
|
|
* changes. The claim re-checks under the lock; this decides nothing alone.
|
|
*/
|
|
export async function isHandoverUsable(
|
|
handoverId: string,
|
|
now: Date = new Date(),
|
|
): Promise<boolean> {
|
|
const row = await prisma.managedProfileHandover.findUnique({
|
|
where: { id: handoverId },
|
|
});
|
|
return (await usableHandover(row, now)) !== null;
|
|
}
|
|
|
|
export interface ClaimedProfile {
|
|
profileId: string;
|
|
username: string;
|
|
displayName: string | null;
|
|
guardians: { guardianId: string; access: HandoverAccess }[];
|
|
}
|
|
|
|
/**
|
|
* Claim the profile behind a link. One transaction; see the module header for
|
|
* everything it does. Throws `HandoverError("invalid")` for every unusable
|
|
* link and `HandoverError("taken")` for a username or email in use.
|
|
*
|
|
* The caller has already hashed the password (Argon2id) and checked its
|
|
* strength: none of that work belongs inside the lock.
|
|
*/
|
|
export async function claimManagedProfile(input: {
|
|
/**
|
|
* The link: the raw token from the password form, or (v1.43) the id the
|
|
* single-sign-on start sealed into the claim cookie after checking the
|
|
* token itself.
|
|
*/
|
|
link: { rawToken: string } | { handoverId: string };
|
|
username: string;
|
|
/** Null on a single-sign-on claim whose provider vouched for no address. */
|
|
email: string | null;
|
|
/** Null on a single-sign-on claim: the account signs in at the provider. */
|
|
passwordHash: string | null;
|
|
/**
|
|
* v1.43 (#959) — the identity provider's subject, bound to the profile in
|
|
* the same transaction. The `(issuer, sub)` unique index refuses a subject
|
|
* that another account holds, as `taken`.
|
|
*/
|
|
oidc?: { issuer: string; sub: string };
|
|
ipAddress?: string | null;
|
|
now?: Date;
|
|
}): Promise<ClaimedProfile> {
|
|
const now = input.now ?? new Date();
|
|
const found =
|
|
"rawToken" in input.link
|
|
? await findHandoverByToken(prisma, input.link.rawToken)
|
|
: await prisma.managedProfileHandover.findUnique({
|
|
where: { id: input.link.handoverId },
|
|
});
|
|
if (!found) throw new HandoverError("invalid");
|
|
|
|
try {
|
|
return await prisma.$transaction((tx) =>
|
|
withManagedProfileLock(tx, found.profileId, async (profile) => {
|
|
if (!profile?.managedProfileAt) throw new HandoverError("invalid");
|
|
|
|
// Re-read under the lock: a new link, a withdrawal or a second claim
|
|
// that committed while this one waited is seen here.
|
|
const row = await tx.managedProfileHandover.findUnique({
|
|
where: { id: found.id },
|
|
});
|
|
if (!row || !isOpen(row, now)) throw new HandoverError("invalid");
|
|
const state = parseHandoverState(row.proposalsJson);
|
|
if (!state) throw new HandoverError("invalid");
|
|
if (!(await isActiveGuardian(tx, profile.id, row.createdById, now))) {
|
|
throw new HandoverError("invalid");
|
|
}
|
|
|
|
// Single use: the conditional update is the claim on the token.
|
|
const consumed = await tx.managedProfileHandover.updateMany({
|
|
where: {
|
|
id: row.id,
|
|
usedAt: null,
|
|
revokedAt: null,
|
|
expiresAt: { gt: now },
|
|
},
|
|
data: { usedAt: now },
|
|
});
|
|
if (consumed.count !== 1) throw new HandoverError("invalid");
|
|
|
|
// The new owner's credentials, field by field. The disclaimer and the
|
|
// onboarding were given by a Guardian on the person's behalf; an adult
|
|
// gives both themselves.
|
|
const updated = await tx.user.update({
|
|
where: { id: profile.id },
|
|
data: {
|
|
username: input.username,
|
|
email: input.email,
|
|
passwordHash: input.passwordHash,
|
|
...(input.oidc
|
|
? { oidcIssuer: input.oidc.issuer, oidcSub: input.oidc.sub }
|
|
: {}),
|
|
disclaimerAcknowledgedAt: null,
|
|
disclaimerAcknowledgedVersion: null,
|
|
onboardingCompletedAt: null,
|
|
},
|
|
select: { username: true, displayName: true },
|
|
});
|
|
await clearManagedProfileMarker({ profileId: profile.id }, tx);
|
|
|
|
const withdrawnInvitations = await withdrawPendingGrantsOfRecord(
|
|
tx,
|
|
profile.id,
|
|
now,
|
|
);
|
|
|
|
const guardians = await tx.accountGrant.findMany({
|
|
where: { grantorId: profile.id, ...activeGuardianWhere(now) },
|
|
select: { id: true, granteeId: true, access: true, scopeJson: true },
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
const entries: StoredClaimEntry[] = [];
|
|
for (const grant of guardians) {
|
|
const proposal = proposalFor(state, grant.id);
|
|
const settled = await settleHandoverGuardianGrant(tx, {
|
|
recordId: profile.id,
|
|
guardianId: grant.granteeId,
|
|
liveGrant: grant,
|
|
target: proposal,
|
|
now,
|
|
});
|
|
entries.push({
|
|
grantId: grant.id,
|
|
guardianId: grant.granteeId,
|
|
proposal,
|
|
applied: settled.access,
|
|
currentGrantId: settled.grantId,
|
|
});
|
|
// Under the Guardian's OWN account, so their history shows that a
|
|
// record they looked after has gone. No actor: it is not somebody
|
|
// else acting on the Guardian's record.
|
|
await auditLog("managed_profile.handed_over", {
|
|
userId: grant.granteeId,
|
|
actorUserId: null,
|
|
details: {
|
|
profileId: profile.id,
|
|
access: settled.access,
|
|
grantId: settled.grantId,
|
|
},
|
|
client: tx,
|
|
});
|
|
}
|
|
|
|
const consent = await withdrawConsentInTransaction(
|
|
tx,
|
|
profile.id,
|
|
consentKindEnum.options,
|
|
now,
|
|
);
|
|
|
|
await tx.managedProfileHandover.update({
|
|
where: { id: row.id },
|
|
data: {
|
|
proposalsJson: toJson({
|
|
...state,
|
|
claim: {
|
|
claimedAt: now.toISOString(),
|
|
guardians: entries,
|
|
decidedAt: null,
|
|
},
|
|
}),
|
|
},
|
|
});
|
|
|
|
await auditLog("managed_profile.claimed", {
|
|
userId: profile.id,
|
|
actorUserId: null,
|
|
ipAddress: input.ipAddress ?? null,
|
|
details: {
|
|
handoverId: row.id,
|
|
guardians: entries.map((e) => ({
|
|
grantId: e.grantId,
|
|
access: e.applied,
|
|
newGrantId: e.currentGrantId,
|
|
})),
|
|
withdrawnInvitations,
|
|
consentsWithdrawn: consent.revoked.length,
|
|
method: input.oidc ? "sso" : "password",
|
|
},
|
|
client: tx,
|
|
});
|
|
|
|
return {
|
|
profileId: profile.id,
|
|
username: updated.username,
|
|
displayName: updated.displayName,
|
|
guardians: entries.map((e) => ({
|
|
guardianId: e.guardianId,
|
|
access: e.applied,
|
|
})),
|
|
};
|
|
}),
|
|
);
|
|
} catch (err) {
|
|
if (isP2002(err)) throw new HandoverError("taken");
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// ── The new owner's decision ────────────────────────────────────────────────
|
|
|
|
export interface PendingHandoverGuardian {
|
|
/** The Guardian's grant at the claim; what a decision names. */
|
|
grantId: string;
|
|
displayName: string;
|
|
proposal: HandoverAccess;
|
|
/** The access the Guardian holds now. */
|
|
current: HandoverAccess;
|
|
/**
|
|
* False when the access moved since the claim — the Guardian stepped away,
|
|
* or the owner already changed it under Settings → Shared access — so the
|
|
* decision screen shows it and leaves it alone.
|
|
*/
|
|
decidable: boolean;
|
|
}
|
|
|
|
export interface PendingHandoverDecision {
|
|
handoverId: string;
|
|
claimedAt: Date;
|
|
guardians: PendingHandoverGuardian[];
|
|
}
|
|
|
|
async function latestClaimedHandover(
|
|
db: Pick<Transaction, "managedProfileHandover">,
|
|
userId: string,
|
|
) {
|
|
return db.managedProfileHandover.findFirst({
|
|
where: { profileId: userId, usedAt: { not: null } },
|
|
orderBy: { usedAt: "desc" },
|
|
select: { id: true, proposalsJson: true },
|
|
});
|
|
}
|
|
|
|
/** Whether the pair's live row is still the one the handover left there. */
|
|
function isUntouched(
|
|
entry: StoredClaimEntry,
|
|
live: { id: string } | null,
|
|
): boolean {
|
|
return entry.currentGrantId === null
|
|
? live === null
|
|
: live?.id === entry.currentGrantId;
|
|
}
|
|
|
|
/**
|
|
* Whether a decision is waiting, for the setup flow's front door. The cheap
|
|
* question; {@link readPendingHandoverDecision} is the full answer.
|
|
*/
|
|
export async function hasPendingHandoverDecision(
|
|
userId: string,
|
|
): Promise<boolean> {
|
|
const row = await latestClaimedHandover(prisma, userId);
|
|
const state = row ? parseHandoverState(row.proposalsJson) : null;
|
|
return Boolean(state?.claim && state.claim.decidedAt === null);
|
|
}
|
|
|
|
/**
|
|
* The decision waiting for the new owner, or null when there is none — the
|
|
* account was never a managed profile, or the decision was made.
|
|
*/
|
|
export async function readPendingHandoverDecision(
|
|
userId: string,
|
|
now: Date = new Date(),
|
|
): Promise<PendingHandoverDecision | null> {
|
|
const row = await latestClaimedHandover(prisma, userId);
|
|
if (!row) return null;
|
|
const state = parseHandoverState(row.proposalsJson);
|
|
if (!state?.claim || state.claim.decidedAt !== null) return null;
|
|
|
|
const ids = state.claim.guardians.map((e) => e.guardianId);
|
|
const [people, liveGrants] = await Promise.all([
|
|
prisma.user.findMany({
|
|
where: { id: { in: ids } },
|
|
select: { id: true, displayName: true, username: true },
|
|
}),
|
|
prisma.accountGrant.findMany({
|
|
where: { grantorId: userId, granteeId: { in: ids }, revokedAt: null },
|
|
select: {
|
|
id: true,
|
|
granteeId: true,
|
|
access: true,
|
|
acceptedAt: true,
|
|
revokedAt: true,
|
|
expiresAt: true,
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const guardians: PendingHandoverGuardian[] = [];
|
|
for (const entry of state.claim.guardians) {
|
|
const person = people.find((p) => p.id === entry.guardianId);
|
|
// A Guardian whose account is gone has nothing left to decide.
|
|
if (!person) continue;
|
|
const live =
|
|
liveGrants.find((g) => g.granteeId === entry.guardianId) ?? null;
|
|
const decidable = isUntouched(entry, live);
|
|
guardians.push({
|
|
grantId: entry.grantId,
|
|
displayName: nameOf(person),
|
|
proposal: entry.proposal,
|
|
current: decidable
|
|
? entry.applied
|
|
: handoverAccessOf(live && isGrantActive(live, now) ? live : null),
|
|
decidable,
|
|
});
|
|
}
|
|
return {
|
|
handoverId: row.id,
|
|
claimedAt: new Date(state.claim.claimedAt),
|
|
guardians,
|
|
};
|
|
}
|
|
|
|
export interface HandoverDecisionResult {
|
|
/** Guardians whose access this decision changed, for their notification. */
|
|
changed: { guardianId: string; access: HandoverAccess }[];
|
|
}
|
|
|
|
/**
|
|
* Record the new owner's final word on each former Guardian.
|
|
*
|
|
* A Guardian the body leaves out keeps the access the claim gave them. One the
|
|
* owner already changed elsewhere, or who stepped away, is left alone. Every
|
|
* change ends a row and writes a new one, as any change of level does.
|
|
*/
|
|
export async function decideHandover(input: {
|
|
userId: string;
|
|
decisions: { grantId: string; access: HandoverAccess }[];
|
|
now?: Date;
|
|
}): Promise<HandoverDecisionResult> {
|
|
const now = input.now ?? new Date();
|
|
return prisma.$transaction((tx) =>
|
|
// The same key every lifecycle act on this record takes. The marker is
|
|
// gone, so nothing here depends on it; the lock orders this against a
|
|
// second decision from another tab.
|
|
withManagedProfileLock(tx, input.userId, async () => {
|
|
const row = await latestClaimedHandover(tx, input.userId);
|
|
const state = row ? parseHandoverState(row.proposalsJson) : null;
|
|
if (!row || !state?.claim || state.claim.decidedAt !== null) {
|
|
throw new HandoverError("no_pending");
|
|
}
|
|
const claim = state.claim;
|
|
// 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");
|
|
}
|
|
|
|
const changed: HandoverDecisionResult["changed"] = [];
|
|
const outcome: {
|
|
grantId: string;
|
|
access: HandoverAccess;
|
|
changed: boolean;
|
|
skipped: boolean;
|
|
}[] = [];
|
|
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;
|
|
const live = await tx.accountGrant.findFirst({
|
|
where: {
|
|
grantorId: input.userId,
|
|
granteeId: entry.guardianId,
|
|
revokedAt: null,
|
|
},
|
|
select: { id: true, access: true, scopeJson: true },
|
|
});
|
|
if (!isUntouched(entry, live)) {
|
|
guardians.push(entry);
|
|
outcome.push({
|
|
grantId: entry.grantId,
|
|
access: target,
|
|
changed: false,
|
|
skipped: true,
|
|
});
|
|
continue;
|
|
}
|
|
const settled = await settleHandoverGuardianGrant(tx, {
|
|
recordId: input.userId,
|
|
guardianId: entry.guardianId,
|
|
liveGrant: live,
|
|
target,
|
|
now,
|
|
});
|
|
guardians.push({
|
|
...entry,
|
|
decided: target,
|
|
currentGrantId: settled.grantId,
|
|
});
|
|
outcome.push({
|
|
grantId: entry.grantId,
|
|
access: target,
|
|
changed: settled.changed,
|
|
skipped: false,
|
|
});
|
|
if (settled.changed) {
|
|
changed.push({ guardianId: entry.guardianId, access: target });
|
|
await auditLog("managed_profile.handover.access_changed", {
|
|
userId: entry.guardianId,
|
|
actorUserId: null,
|
|
details: { profileId: input.userId, access: target },
|
|
client: tx,
|
|
});
|
|
}
|
|
}
|
|
|
|
await tx.managedProfileHandover.update({
|
|
where: { id: row.id },
|
|
data: {
|
|
proposalsJson: toJson({
|
|
...state,
|
|
claim: { ...claim, guardians, decidedAt: now.toISOString() },
|
|
}),
|
|
},
|
|
});
|
|
await auditLog("managed_profile.handover.decided", {
|
|
userId: input.userId,
|
|
details: { handoverId: row.id, decisions: outcome },
|
|
client: tx,
|
|
});
|
|
return { changed };
|
|
}),
|
|
);
|
|
}
|