Merge remote-tracking branch 'origin/main' into fix/v1434-illness-symptoms

This commit is contained in:
Marc-André Bombeck committed 2026-10-17 11:35:55 +02:00
commit e8e3cf4cd3
47 files changed
+2646 -553

No files matched your search

@@ -8,6 +8,8 @@ vi.mock("@/lib/db", () => ({
apiToken: { findUnique: vi.fn(), update: vi.fn() },
user: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn() },
// The stale refusal names the proofs the account holds, passkeys among them.
passkey: { count: vi.fn() },
},
}));
@@ -16,6 +16,8 @@ vi.mock("@/lib/db", () => ({
apiToken: { findUnique: vi.fn(), update: vi.fn() },
user: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn() },
// The stale refusal names the proofs the account holds, passkeys among them.
passkey: { count: vi.fn() },
stepUpElevation: { findUnique: vi.fn() },
$queryRaw: vi.fn(),
},
+79 -9
View File
@@ -82,6 +82,46 @@ describe("requireFreshMfa", () => {
});
});
/**
* The stale refusal names what clears it, so the browser can open the
* re-proof dialog instead of printing a sentence (#959): the possession
* proofs the account holds, never a password, which stamps `reproofAt` only
* and would leave this gate shut.
*/
it("names the possession proofs on a stale refusal", async () => {
mockSession({ ...MFA_USER, passwordHash: "argon2-hash" });
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
vi.mocked(prisma.session.findUnique).mockResolvedValue({
mfaVerifiedAt: new Date(Date.now() - 10 * 60_000),
} as never);
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
errorCode: "auth.stepup.required",
extraMeta: { methods: ["totp", "webauthn", "passkey"] },
});
});
it("offers a passkey-only account its passkey, not its password", async () => {
mockSession({
...MFA_USER,
totpConfirmedAt: null,
passwordHash: "argon2-hash",
});
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
vi.mocked(prisma.session.findUnique).mockResolvedValue({
mfaVerifiedAt: null,
} as never);
await expect(
requireFreshMfa(5 * 60, "any-possession"),
).rejects.toMatchObject({
errorCode: "auth.stepup.required",
extraMeta: { methods: ["passkey"] },
});
});
it("rejects an absent verification stamp", async () => {
mockSession(MFA_USER);
vi.mocked(prisma.session.findUnique).mockResolvedValue({
@@ -93,9 +133,10 @@ describe("requireFreshMfa", () => {
);
});
it("rejects a user with neither TOTP nor a security key", async () => {
it("rejects a user with neither TOTP nor a security key nor a passkey", async () => {
mockSession({ ...MFA_USER, totpConfirmedAt: null });
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never);
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
errorCode: "auth.stepup.mfa_not_enrolled",
});
@@ -123,21 +164,50 @@ describe("requireFreshMfa", () => {
* parameter, and it changes nothing else: the freshness read below is the same
* read either way.
*
* A primary passkey is deliberately outside the default set and inside
* `"any-possession"`. It DOES stamp `mfaVerifiedAt` on login, so it can clear
* the gate — but counting it by default would pull every passkey-holding
* Since v1.43.3 a primary passkey counts by default: `requireFreshMfa`
* defaults to `"any-possession"`, so a passkey-only account clears the
* managed-profile acts with a passkey re-proof. It DOES stamp `mfaVerifiedAt`
* on login and on re-proof, so it can clear the gate. What stays narrow is
* `hasSecondFactorEnrolled`: widening THAT would pull every passkey-holding
* account into `requireFreshMfaIfEnrolled`'s gate on account deletion, the
* data reset, the password change, the encrypted export and key rotation,
* none of which have ever applied to them. The sibling suite
* `require-fresh-mfa-if-enrolled.test.ts` is what catches that if the two
* predicates are ever collapsed into one.
* data reset, the password change, the encrypted export and key rotation.
* The sibling suite `require-fresh-mfa-if-enrolled.test.ts` is what catches
* that if the two predicates are ever collapsed into one.
*/
it("admits a passkey-only account by default, with a fresh stamp", async () => {
mockSession({ ...MFA_USER, totpConfirmedAt: null });
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
vi.mocked(prisma.session.findUnique).mockResolvedValue({
mfaVerifiedAt: new Date(Date.now() - 60_000),
} as never);
const ctx = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
expect(ctx.user.id).toBe("user-1");
});
it("asks a passkey-only account for its passkey by default when the stamp is stale", async () => {
mockSession({ ...MFA_USER, totpConfirmedAt: null });
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
vi.mocked(prisma.session.findUnique).mockResolvedValue({
mfaVerifiedAt: null,
} as never);
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
errorCode: "auth.stepup.required",
extraMeta: { methods: ["passkey"] },
});
});
it("refuses a passkey-only account when only a second factor counts", async () => {
mockSession({ ...MFA_USER, totpConfirmedAt: null });
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
await expect(
requireFreshMfa(5 * 60, "second-factor"),
).rejects.toMatchObject({
errorCode: "auth.stepup.mfa_not_enrolled",
});
});
+55 -8
View File
@@ -1253,11 +1253,13 @@ export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date };
* the route is protecting.
*
* `second-factor` — a confirmed TOTP secret or a registered second-factor
* security key. The default, and the right question for the routes that MANAGE
* a second factor: there is nothing there to protect if nothing is enrolled.
* security key. The default of `requireMfaManagementAuth`, and the right
* question for the routes that MANAGE a second factor: there is nothing there
* to protect if nothing is enrolled.
*
* `any-possession` — the above, OR a registered PRIMARY passkey. The right
* question for a route protecting the passkey itself, because a passkey is a
* `any-possession` — the above, OR a registered PRIMARY passkey. The default
* of `requireFreshMfa` since v1.43.3, and the right question for a route
* protecting the passkey itself, because a passkey is a
* possession factor in its own right and this codebase already treats it as
* one in three places: `/api/auth/passkey/login-verify` stamps `mfaVerifiedAt`
* on a passkey login (v1.23, M-review M1), `passkey` is in
@@ -1270,6 +1272,37 @@ export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date };
*/
export type FreshFactorProofSource = "second-factor" | "any-possession";
/*
* A PASSKEY COUNTS AS A SECOND FACTOR FOR THE UNCONDITIONAL STEP-UP GATE.
*
* Maintainer decision, v1.43.3. `requireFreshMfa` defaults to
* `any-possession`, so an account whose only strong credential is a passkey
* is no longer refused `mfa_not_enrolled` on the routes that call it directly
* (creating, editing, deleting and handing over a managed profile, inviting
* and removing a guardian, the admin key rotation). It clears the gate the
* way every other account does: a passkey sign-in or a passkey re-proof at
* `POST /api/auth/reproof` stamps `Session.mfaVerifiedAt`, and the gate reads
* that stamp inside the same five minutes.
*
* The limits, each deliberate:
*
* - `hasSecondFactorEnrolled` keeps its narrow meaning. It decides whether
* the CONDITIONAL gates (`requireFreshMfaIfEnrolled`,
* `requireFreshMfaOrElevationIfEnrolled`, `requireRecentProof`'s branches,
* the enrollment proofs) apply at all. Widening it would newly gate
* account deletion, the data reset, the password change and the export
* for every passkey holder, take the password re-proof away from them, and
* change what the native app's Bearer requests need. None of that is part
* of the decision.
* - Password login is untouched. It asks for a second factor from
* `totpConfirmedAt` and the security-key count only; a passkey-only
* account signing in with its password is not challenged, as before.
* - The MFA-management routes (`requireMfaManagementAuth`) still default to
* `second-factor`: disabling TOTP or rotating recovery codes on an account
* with no second factor has nothing to protect.
* - Cookie only, as ever. The Bearer path is not reached from here.
*/
/**
* v1.23 — step-up gate. Passes only for a COOKIE session whose
* `Session.mfaVerifiedAt` is within `maxAgeSeconds` AND whose user holds a
@@ -1285,12 +1318,13 @@ export type FreshFactorProofSource = "second-factor" | "any-possession";
* waves gate account deletion, key rotation, and passphrase export on it.
*
* @param proofSource which credentials count as "can prove a factor". Defaults
* to the second-factor set every existing caller means; see
* {@link FreshFactorProofSource}.
* to `any-possession`, so a primary passkey counts (see the decision note
* below {@link FreshFactorProofSource}); `requireMfaManagementAuth` passes
* `second-factor` explicitly.
*/
export async function requireFreshMfa(
maxAgeSeconds: number,
proofSource: FreshFactorProofSource = "second-factor",
proofSource: FreshFactorProofSource = "any-possession",
): Promise<FreshMfaContext> {
const sessionData = await getSession();
if (!sessionData)
@@ -1321,7 +1355,20 @@ export async function requireFreshMfa(
});
const verifiedAt = row?.mfaVerifiedAt ?? null;
if (!verifiedAt || verifiedAt.getTime() < Date.now() - maxAgeSeconds * 1000) {
throw new StepUpRequiredError();
// Name the proofs that clear this gate, the way the recent-proof gate
// does, so the browser can open the re-proof dialog instead of printing a
// sentence with nowhere to act on it. Only the possession proofs: a
// `POST /api/auth/reproof` with one of them stamps `mfaVerifiedAt`, a
// password stamps `reproofAt` only and would leave this gate shut.
const methods = (await recentProofMethods(sessionData.user)).filter(
(method) => method !== "password",
);
annotate({ meta: { step_up_methods: methods.join(",") } });
throw new StepUpRequiredError(
"auth.stepup.required",
"Recent second-factor verification required",
{ methods },
);
}
return { ...sessionData, authMethod: "cookie", mfaVerifiedAt: verifiedAt };
+7
View File
@@ -21,6 +21,13 @@ import { prisma } from "@/lib/db";
* a behaviour change to five destructive actions, made in passing, for the
* benefit of a sixth. `canProveFreshFactor` in `api-handler.ts` is where the wider question
* lives.
*
* Since v1.43.3 a passkey DOES count as a second factor where the gate is
* unconditional: `requireFreshMfa` defaults to `any-possession`, so a
* passkey-only account clears the managed-profile acts with a passkey
* re-proof instead of being told to enrol something. That decision was made
* there and not here, on purpose; the note beside `FreshFactorProofSource`
* lists what it does and does not change.
*/
export async function hasSecondFactorEnrolled(user: {
id: string;
+2 -2
View File
@@ -75,7 +75,7 @@ const accountGrantInvite = inviteGrantSchema.meta({
const createManagedProfileRequest = createManagedProfileSchema.meta({
id: "CreateManagedProfileRequest",
description:
"Create a health record for somebody who has no login of their own — a child, a dependent adult, an animal. The caller becomes its first Guardian in the same transaction, through an ordinary MANAGE grant with the new record as grantor, so the profile appears in `accountAccess.accounts` on the next `GET /api/auth/me` and is switched into like any other shared record. `displayName` is the only required text; `dateOfBirth` is an optional `YYYY-MM-DD` and is never synthesised from a year. `gender` is optional and takes the same three values `PATCH /api/auth/me` takes for an ordinary account (`MALE`, `FEMALE`, `OTHER`) or null; absent and null both mean not recorded, and the value is what the cycle module derives its default from, so a record created for a child who does not need it starts with it off. `locale` and `timezone` belong to the RECORD and not to the Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent management relationship, and an account with no second factor enrolled is refused rather than waved through. Rate-limited to ten an hour per caller.",
"Create a health record for somebody who has no login of their own — a child, a dependent adult, an animal. The caller becomes its first Guardian in the same transaction, through an ordinary MANAGE grant with the new record as grantor, so the profile appears in `accountAccess.accounts` on the next `GET /api/auth/me` and is switched into like any other shared record. `displayName` is the only required text; `dateOfBirth` is an optional `YYYY-MM-DD` and is never synthesised from a year. `gender` is optional and takes the same three values `PATCH /api/auth/me` takes for an ordinary account (`MALE`, `FEMALE`, `OTHER`) or null; absent and null both mean not recorded, and the value is what the cycle module derives its default from, so a record created for a child who does not need it starts with it off. `locale` and `timezone` belong to the RECORD and not to the Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent management relationship, and an account with no second factor enrolled is refused rather than waved through. A primary passkey counts as that factor here: a passkey sign-in or a passkey re-proof at POST /api/auth/reproof stamps the session. Rate-limited to ten an hour per caller.",
});
const updateManagedProfileRequest = updateManagedProfileSchema.meta({
@@ -1089,7 +1089,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"401": {
description:
"No fresh second-factor proof (`meta.errorCode: auth.stepup.required`), or none enrolled at all (`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account without a second factor cannot create a managed profile until it enrols one. The ordinary unauthenticated 401 arrives here too, including for a Bearer caller, whose transport this gate does not read.",
"No fresh second-factor proof (`meta.errorCode: auth.stepup.required`, with `meta.methods` naming the proofs that clear it at POST /api/auth/reproof), or none enrolled at all (`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account with neither a second factor nor a passkey cannot create a managed profile until it enrols one. A passkey counts: an account whose only strong credential is a passkey is offered `passkey` in `meta.methods`. The ordinary unauthenticated 401 arrives here too, including for a Bearer caller, whose transport this gate does not read.",
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
+1 -1
View File
@@ -1099,7 +1099,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Auth"],
summary: "Re-prove a credential on a browser session (cookie only)",
description:
"Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/workouts, POST /api/tokens/documents, and the admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five minutes ago.\n\n" +
"Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/workouts, POST /api/tokens/documents, and the admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five minutes ago. A `totp`, `webauthn` or `passkey` proof here also clears the routes that ask for a fresh second factor (the managed-profile acts, the second-factor and passkey changes, the password change, account deletion, the data wipe and key rotation): on a browser session those answer 401 `auth.stepup.required` with `meta.methods` naming the possession proofs the account holds, and the web re-proves here and retries.\n\n" +
'The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also refreshes the session\'s second-factor stamp, exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.\n\n' +
"Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that does not verify is 401 `auth.reproof.failed` and is audited. Bearer callers use POST /api/auth/step-up instead.",
requestBody: {
@@ -116,7 +116,9 @@ describe("what a managed-profile change refreshes", () => {
describe("the creation hook, driven for real", () => {
it("posts the four fields the strict schema accepts, and no others", async () => {
const options = useCreateManagedProfile() as unknown as {
const options = useCreateManagedProfile((action) =>
action(),
) as unknown as {
mutationFn: (input: unknown) => Promise<unknown>;
onSuccess: () => void;
};
@@ -147,7 +149,9 @@ describe("the creation hook, driven for real", () => {
});
it("puts the new profile in the switcher by refreshing the payload", () => {
const options = useCreateManagedProfile() as unknown as {
const options = useCreateManagedProfile((action) =>
action(),
) as unknown as {
onSuccess: () => void;
};
options.onSuccess();
+48 -23
View File
@@ -108,21 +108,34 @@ function useInvalidateManagedProfiles() {
return () => invalidateManagedProfileReads(queryClient);
}
/**
* Runs a request that the server may hold back for a fresh proof — in a
* component, `useRecentProof().run`.
*
* Every write in this family resolves `requireFreshMfa`, whose 401
* `auth.stepup.required` names the proofs that clear it in `meta.methods`.
* The guard opens the re-proof dialog on that answer, posts the proof and
* sends the request once more, so the mutation settles with the real outcome
* instead of a sentence telling somebody to confirm a factor with nowhere to
* do it. The argument is required rather than defaulted: a caller that forgot
* it would compile, render the refusal as text, and be the defect again.
*/
export type ProofGuard = <T>(action: () => Promise<T>) => Promise<T>;
/**
* Create a record somebody looks after.
*
* `requireFreshMfa` guards the route, so a 401 carrying
* `meta.errorCode: auth.stepup.required` is the EXPECTED first answer for
* anybody who has a second factor and has not proved it recently. The form
* routes that into the app's re-verification path rather than into a generic
* failure — see `managed-profile-create-form.tsx`.
* anybody who has a second factor and has not proved it recently. `guard`
* turns it into the re-proof dialog and a second attempt.
*/
export function useCreateManagedProfile() {
export function useCreateManagedProfile(guard: ProofGuard) {
const invalidate = useInvalidateManagedProfiles();
return useMutation({
mutationKey: queryKeys.managedProfileCreate(),
mutationFn: (input: CreateManagedProfileInput) =>
apiPost<ManagedProfileView>("/api/managed-profiles", input),
guard(() => apiPost<ManagedProfileView>("/api/managed-profiles", input)),
onSuccess: invalidate,
});
}
@@ -151,21 +164,23 @@ export function useManagedProfile(profileId: string, enabled = true) {
*
* Guarded by `requireFreshMfa` like every other act in this family, so a 401
* carrying `meta.errorCode: auth.stepup.required` is the expected first answer
* rather than a failure — the form routes it into the re-verification path.
* rather than a failure — `guard` routes it into the re-proof dialog.
*
* The whole family is invalidated on success because the display name is what
* the switcher, the record banner and this card all identify the record by, and
* the record's own identity read is dropped alongside so a second edit opens
* from the server's version rather than from the one that was just replaced.
*/
export function useUpdateManagedProfile() {
export function useUpdateManagedProfile(guard: ProofGuard) {
const queryClient = useQueryClient();
return useMutation({
mutationKey: queryKeys.managedProfileUpdate(),
mutationFn: (input: UpdateManagedProfileInput) =>
apiPatch<ManagedProfileView>(
`/api/managed-profiles/${input.profileId}`,
managedProfileEditBody(input),
guard(() =>
apiPatch<ManagedProfileView>(
`/api/managed-profiles/${input.profileId}`,
managedProfileEditBody(input),
),
),
onSuccess: (_result, input) => {
invalidateManagedProfileReads(queryClient);
@@ -250,12 +265,14 @@ export function useManagedProfileGuardians(profileId: string, enabled = true) {
* floor. A Guardian may not strand a record with nobody looking after it, but
* they may end the record.
*/
export function useDeleteManagedProfile() {
export function useDeleteManagedProfile(guard: ProofGuard) {
const invalidate = useInvalidateManagedProfiles();
return useMutation({
mutationKey: queryKeys.managedProfileDelete(),
mutationFn: (profileId: string) =>
apiDelete<{ deleted: true }>(`/api/managed-profiles/${profileId}`),
guard(() =>
apiDelete<{ deleted: true }>(`/api/managed-profiles/${profileId}`),
),
onSuccess: invalidate,
});
}
@@ -285,14 +302,16 @@ export interface InviteManagedProfileGuardianInput {
* here accepts on their behalf, and until they accept they do not count toward
* the last-Guardian floor.
*/
export function useInviteManagedProfileGuardian() {
export function useInviteManagedProfileGuardian(guard: ProofGuard) {
const invalidate = useInvalidateManagedProfiles();
return useMutation({
mutationKey: queryKeys.managedProfileGuardianInvite(),
mutationFn: (input: InviteManagedProfileGuardianInput) =>
apiPost(
`/api/managed-profiles/${input.profileId}/guardians`,
guardianInviteBody(input),
guard(() =>
apiPost(
`/api/managed-profiles/${input.profileId}/guardians`,
guardianInviteBody(input),
),
),
onSuccess: invalidate,
});
@@ -328,7 +347,7 @@ export function guardianInviteBody(input: InviteManagedProfileGuardianInput): {
* `variables` carries both ids so a refusal can be rendered on the guardian row
* it belongs to rather than on the panel.
*/
export function useRemoveManagedProfileGuardian() {
export function useRemoveManagedProfileGuardian(guard: ProofGuard) {
const invalidate = useInvalidateManagedProfiles();
return useMutation({
mutationKey: queryKeys.managedProfileGuardianRemove(),
@@ -338,7 +357,10 @@ export function useRemoveManagedProfileGuardian() {
}: {
profileId: string;
grantId: string;
}) => apiDelete(`/api/managed-profiles/${profileId}/guardians/${grantId}`),
}) =>
guard(() =>
apiDelete(`/api/managed-profiles/${profileId}/guardians/${grantId}`),
),
onSuccess: invalidate,
});
}
@@ -409,19 +431,22 @@ export function useManagedProfileHandover(profileId: string, enabled = true) {
/**
* Mint the handover link. Step-up gated like every act in this family, so a
* 401 carrying `auth.stepup.required` is the expected first answer.
* 401 carrying `auth.stepup.required` is the expected first answer, and
* `guard` turns it into the re-proof dialog and a second attempt.
*
* The link lives in the mutation's `data` and nowhere else: it is shown once
* and never cached under a query key.
*/
export function useCreateManagedProfileHandover() {
export function useCreateManagedProfileHandover(guard: ProofGuard) {
const queryClient = useQueryClient();
return useMutation({
mutationKey: queryKeys.managedProfileHandoverCreate(),
mutationFn: async (input: CreateManagedProfileHandoverInput) => {
const link = await apiPost<ManagedProfileHandoverLink>(
`/api/managed-profiles/${input.profileId}/handover`,
handoverCreateBody(input),
const link = await guard(() =>
apiPost<ManagedProfileHandoverLink>(
`/api/managed-profiles/${input.profileId}/handover`,
handoverCreateBody(input),
),
);
// Lazy chunk, as the admin invite and the share link do: the QR encoder
// is needed here, at mint time, and nowhere else.