feat(sharing): hand a managed profile over to the person it describes

A guardian with a fresh second factor mints a one-time handover link
(hlp_ token, HMAC hash stored only, 1/7/14 days, at most one open per
profile, withdrawable) with a proposed access level per guardian: end,
view only or manage, view only by default. The link is shown once with a
copy button and a QR code; nothing is mailed. The panel sits at the end
of each managed-profile row and hints when the person is under 16.

The person opens /claim/<token>, sees a minimal preview (name, expiry,
who keeps which access, no health data) and chooses a username, a
required email and a password under the registration policy. The claim
is one transaction under the managed-profile lock: the token is
consumed, the credentials are set, the marker is cleared, every pending
invitation the record offered is withdrawn, each guardian's access is
set to its proposal, the disclaimer, onboarding and AI consent are
reset, and every step is audited on the record and on each guardian's
own account. A failure anywhere leaves the profile managed and the link
unused. Each guardian is notified on their own channels.

The new owner then sees each guardian's access on the first screen after
signing in and decides finally; until then the proposal holds, and the
decision waits at the top of Shared access. A guardian whose access
moved since the claim is left alone. The same screen points the owner
at their own notification channels, since reminders no longer reach the
guardians.

Both anonymous routes refuse a single-sign-on-only instance, refuse a
live session before spending the per-address rate limit, check the link
before probing names, and answer one 404 for every unusable token. The
token travels in request bodies only; hlp_ joins the redaction and
secret-shape rules and /claim/ the path-secret registry. Usernames
starting with managed- are reserved. The session-surface and acting-
account guards list the new surfaces with their reasons.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 11:18:14 +02:00
1 parent 321ed1241c
commit 8617f809f0
59 files changed
+7148 -695

No files matched your search

+7
View File
@@ -234,6 +234,8 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"managed_profile.guardian.duplicate",
"managed_profile.guardian.managed_invitee",
"managed_profile.guardian.required",
"managed_profile.handover.no_pending",
"managed_profile.handover.unknown_guardian",
"managed_profile.not_found",
],
measurement: [
@@ -315,6 +317,11 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"profile.update.invalidBody",
"profile.update.nothingSaved",
],
profile_claim: [
"profile_claim.invalid",
"profile_claim.oidc_only_unsupported",
"profile_claim.taken",
],
record_write: ["record_write.rate_limited"],
"report-selection": [
"report-selection.body.invalid_json",
+162 -32
View File
@@ -36,14 +36,14 @@ import {
inviteManagedProfileGuardianSchema,
updateManagedProfileSchema,
} from "@/lib/validations/managed-profiles";
import {
createHandoverSchema,
handoverAccessSchema,
handoverDecisionSchema,
} from "@/lib/validations/managed-profile-handover";
import { onboardingStateResource } from "./onboarding";
import { aiAccountBlock, moduleAccessMap } from "./profile";
import {
dataEnvelope,
errorEnvelope,
notImplementedResponse,
stdResponses,
} from "./shared";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
/**
* The four request bodies this table publishes, and the one thing to know about
@@ -551,32 +551,17 @@ const managedProfileEnvelope = dataEnvelope(
);
// v1.42 (#959) — handing a managed profile over to the person it describes.
const handoverProposal = z
.enum(["end", "read", "manage"])
const handoverAccess = handoverAccessSchema
.describe(
"What a guardian's access becomes after the handover: ended, view only, or still managing. The new owner confirms or changes it when claiming.",
"What a guardian's access becomes after the handover: ended (`end`), view only (`read`), or still managing (`manage`).",
)
.meta({ id: "ManagedProfileHandoverProposal" });
.meta({ id: "ManagedProfileHandoverAccess" });
const createHandoverRequest = z
.object({
expiresInDays: z
.union([z.literal(1), z.literal(7), z.literal(14)])
.default(7)
.describe("How long the link stays valid."),
proposals: z
.array(
z.object({
grantId: z.string().describe("The guardian's MANAGE grant."),
proposal: handoverProposal,
}),
)
.describe(
"One proposal per active guardian. A guardian left out is proposed `read`.",
),
})
.strict()
.meta({ id: "CreateManagedProfileHandoverRequest" });
const createHandoverRequest = createHandoverSchema.meta({
id: "CreateManagedProfileHandoverRequest",
description:
"`expiresInDays` is 1, 7 (default) or 14. `proposals` names at most one entry per active guardian grant; a guardian left out is proposed `read`, and a grant that is not an active guardian of this profile is refused with 422.",
});
const managedProfileHandover = z
.object({
@@ -591,13 +576,93 @@ const managedProfileHandover = z
})
.meta({ id: "ManagedProfileHandover" });
const managedProfileHandoverStatus = z
.object({
available: z
.boolean()
.describe(
"False on an instance that allows single sign-on only, where a handover cannot be created.",
),
open: z
.object({
id: z.string(),
createdAt: z.iso.datetime({ offset: true }),
expiresAt: z.iso.datetime({ offset: true }),
createdByYou: z.boolean(),
})
.nullable()
.describe(
"The profile's open link, or null. Never the link itself: only its hash is stored.",
),
})
.meta({ id: "ManagedProfileHandoverStatus" });
const handoverDecisionRequest = handoverDecisionSchema.meta({
id: "HandoverDecisionRequest",
description:
"The new owner's decision per former guardian, named by the guardian's grant id at the claim. A guardian left out keeps the access the claim gave them.",
});
const pendingHandoverDecision = z
.object({
pending: z
.object({
claimedAt: z.iso.datetime({ offset: true }),
guardians: z.array(
z.object({
grantId: z
.string()
.describe(
"The guardian's grant at the claim; what a decision names.",
),
displayName: z.string(),
proposal: handoverAccess,
current: handoverAccess,
decidable: z
.boolean()
.describe(
"False when the access moved since the claim (the guardian stepped away, or it was changed under shared access); such a row is shown and left alone.",
),
}),
),
})
.nullable(),
})
.meta({ id: "PendingHandoverDecision" });
export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/managed-profiles/{id}/handover": {
get: {
tags: ["Account sharing"],
summary: "Whether a handover link is open",
description:
"The profile's open handover link, if any, and whether this instance allows creating one. Never returns the link: only its hash is stored. Cookie-only, no step-up.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
...stdResponses,
"200": {
description: "The status.",
content: {
"application/json": {
schema: dataEnvelope(
managedProfileHandoverStatus,
"ManagedProfileHandoverStatusEnvelope",
),
},
},
},
"404": {
description:
"No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`).",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
post: {
tags: ["Account sharing"],
summary: "Create a handover link for a managed profile",
description:
"Mints the one-time link that lets the person a managed profile describes take it over as their own account. No data moves: claiming sets credentials on the profile's own record. Each guardian's access afterwards follows the proposal recorded here unless the new owner changes it. Creating a new link withdraws an open one. Pending MANAGE invitations on the profile are withdrawn when it is claimed. Cookie-only and step-up gated, like every act on a managed profile. Refused on an instance that allows single sign-on only.",
"Mints the one-time link that lets the person a managed profile describes take it over as their own account. No data moves: claiming sets credentials on the profile's own record. Claiming applies each guardian's proposal at once; the new owner then confirms or changes it on first sign-in, and until they do the proposal holds. Creating a new link withdraws an open one. Pending invitations on the profile are withdrawn when it is claimed. Cookie-only and step-up gated, like every act on a managed profile; ten an hour per caller. Refused on an instance that allows single sign-on only.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
@@ -626,7 +691,11 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`).",
content: { "application/json": { schema: errorEnvelope } },
},
...notImplementedResponse,
"422": {
description:
"The body is invalid, or a proposal names a grant that is not an active guardian of this profile (`meta.errorCode: managed_profile.handover.unknown_guardian`).",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
delete: {
@@ -653,7 +722,68 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`).",
content: { "application/json": { schema: errorEnvelope } },
},
...notImplementedResponse,
},
},
},
"/api/account/handover-decision": {
get: {
tags: ["Account sharing"],
summary: "The handover decision waiting for a new owner",
description:
"After claiming a managed profile, the new owner decides each former guardian's access. This answers that decision, or `pending: null` when none is waiting. Refused while acting on another account.",
responses: {
...stdResponses,
"200": {
description: "The waiting decision, or null.",
content: {
"application/json": {
schema: dataEnvelope(
pendingHandoverDecision,
"PendingHandoverDecisionEnvelope",
),
},
},
},
},
},
post: {
tags: ["Account sharing"],
summary: "Decide each former guardian's access",
description:
"Records the new owner's final decision per former guardian. A changed level ends the guardian's grant and writes a new one; `manage` can restore access a guardian held before the handover. After this, access changes through the ordinary grant endpoints. Refused while acting on another account.",
requestBody: {
required: true,
content: { "application/json": { schema: handoverDecisionRequest } },
},
responses: {
...stdResponses,
"200": {
description: "Decided.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
decided: z.literal(true),
changed: z
.number()
.int()
.describe("How many guardians' access this changed."),
}),
"HandoverDecisionEnvelope",
),
},
},
},
"409": {
description:
"No decision is waiting (`meta.errorCode: managed_profile.handover.no_pending`).",
content: { "application/json": { schema: errorEnvelope } },
},
"422": {
description:
"The body is invalid, or a decision names somebody who was not a guardian at the claim (`meta.errorCode: managed_profile.handover.unknown_guardian`).",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
+31 -35
View File
@@ -46,9 +46,13 @@ import {
stdResponses,
errorEnvelope,
loginPasswordSchema,
notImplementedResponse,
} from "./shared";
import { passkeyLoginOptionsSchema } from "@/lib/validations/auth";
import {
claimPreviewSchema,
claimSchema,
} from "@/lib/validations/managed-profile-handover";
import { HANDOVER_ACCESS_LEVELS } from "@/lib/managed-profiles/handover-access";
// ── Sub-schemas owned here (route-specific shapes) ───────────────────
@@ -699,48 +703,42 @@ const codexDevicePollResponse = z
.meta({ id: "CodexDevicePollResponse" });
// v1.42 (#959) — claiming a managed profile through its handover link.
const claimTokenField = z
.string()
.describe(
"The `hlp_` token from the handover link. Sent in the body, never in a URL.",
);
const claimPreviewRequest = claimPreviewSchema.meta({
id: "ProfileClaimPreviewRequest",
description:
"`token` is the `hlp_` token from the handover link. Sent in the body, never in a URL.",
});
const claimPreviewRequest = z
.object({ token: claimTokenField })
.strict()
.meta({ id: "ProfileClaimPreviewRequest" });
const claimAccess = z
.enum(HANDOVER_ACCESS_LEVELS)
.meta({ id: "ProfileClaimGuardianAccess" });
const claimPreview = z
.object({
displayName: z.string(),
displayName: z
.string()
.nullable()
.describe("The profile's name, as its guardians set it."),
expiresAt: z.iso.datetime({ offset: true }),
guardians: z.array(
z.object({
grantId: z.string(),
displayName: z.string(),
proposal: z.enum(["end", "read", "manage"]),
}),
),
})
.meta({ id: "ProfileClaimPreview" });
const claimRequest = z
.object({
token: claimTokenField,
username: z.string(),
email: z.string().describe("Required: the account needs a way back in."),
password: z.string(),
guardians: z
.array(
z.object({
grantId: z.string(),
decision: z.enum(["end", "read", "manage"]),
displayName: z.string(),
proposal: claimAccess,
}),
)
.describe("The new owner's decision per guardian."),
.describe(
"Each guardian and the access they keep after the claim, as proposed. The new owner confirms or changes it after signing in.",
),
})
.strict()
.meta({ id: "ProfileClaimRequest" });
.meta({ id: "ProfileClaimPreview" });
const claimRequest = claimSchema.meta({
id: "ProfileClaimRequest",
description:
"`token` is the `hlp_` token from the handover link, in the body. `username` follows the registration rules and may not start with `managed-`; `email` is required, because the account needs a way back in; `password` follows the registration password policy.",
});
export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/auth/claim/preview": {
@@ -781,7 +779,6 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...notImplementedResponse,
},
},
},
@@ -792,7 +789,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Auth"],
summary: "Claim a managed profile",
description:
"Turns the managed profile behind a handover link into the claimant's own account: sets the username, email and password on the profile's record, clears the managed marker, settles each guardian's access by the claimant's decision, withdraws pending MANAGE invitations, and resets the disclaimer, onboarding and AI consent so the new owner gives them personally. No data moves. Signs the new owner in. Anonymous; the token travels in the body, and every token failure answers the same 404.",
"Turns the managed profile behind a handover link into the claimant's own account, in one transaction: sets the username, email and password on the profile's record, clears the managed marker, withdraws pending invitations, applies each guardian's proposed access, and resets the disclaimer, onboarding and AI consent so the new owner gives them personally. The new owner then decides each guardian's access on first sign-in (`/api/account/handover-decision`). No data moves. Signs the new owner in. Anonymous; the token travels in the body, every token failure answers the same 404, and five attempts per fifteen minutes per source address are allowed.",
requestBody: {
required: true,
content: { "application/json": { schema: claimRequest } },
@@ -821,11 +818,10 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"409": {
description:
"The request carries a signed-in session (`meta.errorCode: auth.already_authenticated`), or the username or email is taken.",
"The request carries a signed-in session (`meta.errorCode: auth.already_authenticated`), or the username or email is taken (`meta.errorCode: profile_claim.taken`).",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...notImplementedResponse,
},
},
},