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

@@ -0,0 +1,109 @@
/**
* `/api/account/handover-decision` — the new owner's final word on each former
* Guardian after claiming a managed profile (v1.42, #959).
*
* The claim applies the access the issuing Guardian proposed, so the record
* is never without a decision. This is where the owner sees it and decides:
*
* - `GET` answers the decision that is waiting, or `pending: null`.
* - `POST` records it. A Guardian the body leaves out keeps what the claim
* gave them; after this, the ordinary Settings → Shared access panel is
* where access changes.
*
* A grant-management surface, resolved like the others (`/api/account/
* grants/**`): bare `requireAuth()`, which refuses under an acting-account
* switch and refuses a narrow Bearer scope. The owner is the session user; no
* party to any grant comes from the body, only the claim-time grant id that
* names which former Guardian a decision is about.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
decideHandover,
HandoverError,
readPendingHandoverDecision,
} from "@/lib/managed-profiles/handover";
import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify";
import { prisma } from "@/lib/db";
import { handoverDecisionSchema } from "@/lib/validations/managed-profile-handover";
export const GET = apiHandler(async (_request: NextRequest) => {
const { user } = await requireAuth();
const pending = await readPendingHandoverDecision(user.id);
annotate({
action: { name: "managed_profile.handover.decision_read" },
meta: { pending: pending !== null },
});
return apiSuccess({
pending: pending
? {
claimedAt: pending.claimedAt.toISOString(),
guardians: pending.guardians,
}
: null,
});
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = handoverDecisionSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
let result;
try {
result = await decideHandover({
userId: user.id,
decisions: parsed.data.decisions.map((d) => ({
grantId: d.grantId,
access: d.access,
})),
});
} catch (error) {
if (error instanceof HandoverError) {
if (error.code === "unknown_guardian") {
return apiError(
"A decision names somebody who was not a Guardian",
422,
{
errorCode: "managed_profile.handover.unknown_guardian",
},
);
}
return apiError("There is no handover decision waiting", 409, {
errorCode: "managed_profile.handover.no_pending",
});
}
throw error;
}
if (result.changed.length > 0) {
const self = await prisma.user.findUnique({
where: { id: user.id },
select: { displayName: true, username: true },
});
notifyGuardiansOfHandover(
"changed",
self?.displayName?.trim() || self?.username || user.username,
result.changed,
);
}
annotate({
action: { name: "managed_profile.handover.decided" },
meta: { changed: result.changed.length },
});
return apiSuccess({ decided: true, changed: result.changed.length });
});
@@ -0,0 +1,266 @@
/**
* v1.42 (#959) — the anonymous claim and its preview, at the route seam.
*
* The transaction itself is integration-tested
* (`tests/integration/managed-profile-handover.test.ts`); here the order of
* the refusals is pinned, because each one is a security property:
*
* - single sign-on only refuses before anything else;
* - a live session is refused BEFORE the rate limit (a state, not an
* attempt), and before any token is read;
* - the rate limit runs before the body is parsed;
* - an unusable token answers the same 404 on both routes, and the claim
* checks the link BEFORE probing whether a username or email is taken, so
* the route is not a name oracle for somebody without a live link;
* - nothing is hashed for a token that is refused.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn(),
getSession: vi.fn(),
}));
vi.mock("@/lib/auth/oidc", () => ({ isOidcOnly: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
checkAuthSurfaceRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/auth/password", () => ({
hashPassword: vi.fn(),
checkPasswordStrength: vi.fn(),
}));
vi.mock("@/lib/password-breach-check", () => ({
checkPasswordBreachIfEnabled: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/auth/login-alert", () => ({ recordSignInDevice: vi.fn() }));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn().mockResolvedValue("en"),
}));
vi.mock("@/lib/managed-profiles/handover", async () => {
const actual = await vi.importActual<
typeof import("@/lib/managed-profiles/handover")
>("@/lib/managed-profiles/handover");
return {
HandoverError: actual.HandoverError,
previewHandover: vi.fn(),
claimManagedProfile: vi.fn(),
};
});
vi.mock("@/lib/managed-profiles/handover-notify", () => ({
notifyGuardiansOfHandover: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST as claim } from "../route";
import { POST as preview } from "../preview/route";
import { prisma } from "@/lib/db";
import { createSession, getSession } from "@/lib/auth/session";
import { isOidcOnly } from "@/lib/auth/oidc";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
import {
claimManagedProfile,
HandoverError,
previewHandover,
} from "@/lib/managed-profiles/handover";
import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify";
const TOKEN = `hlp_${"a".repeat(64)}`;
const BODY = {
token: TOKEN,
username: "alex",
email: "alex@example.test",
password: "a long enough passphrase",
};
function post(path: string, body: unknown): NextRequest {
return new NextRequest(`http://localhost${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const PREVIEW = {
displayName: "Alex",
expiresAt: new Date("2030-01-01T00:00:00Z"),
guardians: [{ grantId: "g1", displayName: "Sam", proposal: "read" as const }],
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(isOidcOnly).mockReturnValue(false);
vi.mocked(getSession).mockResolvedValue(null as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
ip: "203.0.113.7",
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
vi.mocked(checkPasswordStrength).mockReturnValue({
isAcceptable: true,
feedback: [],
} as never);
vi.mocked(checkPasswordBreachIfEnabled).mockResolvedValue(null as never);
vi.mocked(hashPassword).mockResolvedValue("argon2-hash");
vi.mocked(previewHandover).mockResolvedValue(PREVIEW);
vi.mocked(claimManagedProfile).mockResolvedValue({
profileId: "p1",
username: "alex",
displayName: "Alex",
guardians: [{ guardianId: "u-sam", access: "read" }],
});
});
async function errorCode(res: Response): Promise<unknown> {
const body = (await res.json()) as { meta?: { errorCode?: string } };
return body.meta?.errorCode;
}
describe.each([
["claim", claim, "/api/auth/claim", BODY],
["preview", preview, "/api/auth/claim/preview", { token: TOKEN }],
] as const)(
"POST %s — refusals that read no token",
(_n, route, path, body) => {
it("refuses on a single-sign-on-only instance before anything else", async () => {
vi.mocked(isOidcOnly).mockReturnValue(true);
vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never);
const res = await route(post(path, body));
expect(res.status).toBe(403);
expect(await errorCode(res)).toBe("profile_claim.oidc_only_unsupported");
expect(previewHandover).not.toHaveBeenCalled();
});
it("refuses over a live session before spending the rate limit", async () => {
vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never);
const res = await route(post(path, body));
expect(res.status).toBe(409);
expect(await errorCode(res)).toBe("auth.already_authenticated");
expect(checkAuthSurfaceRateLimit).not.toHaveBeenCalled();
expect(previewHandover).not.toHaveBeenCalled();
});
it("answers 429 from the per-address bucket before parsing the body", async () => {
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
ip: "203.0.113.7",
} as never);
const res = await route(post(path, body));
expect(res.status).toBe(429);
expect(previewHandover).not.toHaveBeenCalled();
});
it("answers the same 404 for an unusable link", async () => {
vi.mocked(previewHandover).mockResolvedValue(null);
const res = await route(post(path, body));
expect(res.status).toBe(404);
expect(await errorCode(res)).toBe("profile_claim.invalid");
});
},
);
describe("POST /api/auth/claim", () => {
it("checks the link before the username and email probe", async () => {
vi.mocked(previewHandover).mockResolvedValue(null);
vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "x" } as never);
const res = await claim(post("/api/auth/claim", BODY));
// A taken name would be a 409; the link is checked first, so 404.
expect(res.status).toBe(404);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
expect(hashPassword).not.toHaveBeenCalled();
});
it("answers 409 profile_claim.taken for a name in use, before hashing", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
id: "x",
} as never);
const res = await claim(post("/api/auth/claim", BODY));
expect(res.status).toBe(409);
expect(await errorCode(res)).toBe("profile_claim.taken");
expect(hashPassword).not.toHaveBeenCalled();
expect(claimManagedProfile).not.toHaveBeenCalled();
});
it("refuses a username with the managed-profile prefix as 422", async () => {
const res = await claim(
post("/api/auth/claim", { ...BODY, username: "managed-alex" }),
);
expect(res.status).toBe(422);
expect(previewHandover).not.toHaveBeenCalled();
});
it("refuses an unknown field (strict body)", async () => {
const res = await claim(
post("/api/auth/claim", { ...BODY, userId: "someone-else" }),
);
expect(res.status).toBe(422);
});
it("maps a race lost inside the transaction to the same 404", async () => {
vi.mocked(claimManagedProfile).mockRejectedValue(
new HandoverError("invalid"),
);
const res = await claim(post("/api/auth/claim", BODY));
expect(res.status).toBe(404);
expect(createSession).not.toHaveBeenCalled();
});
it("maps a unique-constraint race to 409 taken", async () => {
vi.mocked(claimManagedProfile).mockRejectedValue(
new HandoverError("taken"),
);
const res = await claim(post("/api/auth/claim", BODY));
expect(res.status).toBe(409);
expect(await errorCode(res)).toBe("profile_claim.taken");
});
it("signs the new owner in with onboarding owed and tells the guardians", async () => {
const res = await claim(post("/api/auth/claim", BODY));
expect(res.status).toBe(201);
expect(claimManagedProfile).toHaveBeenCalledWith(
expect.objectContaining({
rawToken: TOKEN,
username: "alex",
email: "alex@example.test",
passwordHash: "argon2-hash",
}),
);
expect(createSession).toHaveBeenCalledWith("p1", true, "203.0.113.7", null);
expect(notifyGuardiansOfHandover).toHaveBeenCalledWith("claimed", "Alex", [
{ guardianId: "u-sam", access: "read" },
]);
const body = (await res.json()) as { data: unknown };
expect(body.data).toEqual({ userId: "p1", username: "alex" });
});
});
describe("POST /api/auth/claim/preview", () => {
it("returns the minimal preview and nothing else", async () => {
const res = await preview(
post("/api/auth/claim/preview", { token: TOKEN }),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
expect(Object.keys(body.data).sort()).toEqual([
"displayName",
"expiresAt",
"guardians",
]);
expect(body.data.expiresAt).toBe("2030-01-01T00:00:00.000Z");
});
});
+45 -6
View File
@@ -1,11 +1,50 @@
/**
* `POST /api/auth/claim/preview` — what a handover link would hand over: the
* profile's display name and each guardian's proposal (v1.42, #959).
* Anonymous, token in the body, the same uniform 404 as the claim itself.
* profile's display name, the link's expiry and each Guardian's proposed
* access (v1.42, #959). No health data.
*
* Contract stub: answers 501.
* Anonymous by design: the person opening the link has no credentials yet.
* The one-time `hlp_` token travels in the BODY, never in this request's URL,
* so it never reaches an access log or a wide event's `http.path`. Every
* unusable link — malformed, unknown, expired, used, withdrawn, a profile no
* longer managed, a creator who lost access — answers the same 404.
*/
import { apiHandler } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { NextRequest } from "next/server";
export const POST = apiHandler(async () => notImplemented());
import { apiHandler } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { getSession } from "@/lib/auth/session";
import { annotate } from "@/lib/logging/context";
import { claimGate, claimInvalid } from "@/lib/managed-profiles/claim-gate";
import { previewHandover } from "@/lib/managed-profiles/handover";
import { claimPreviewSchema } from "@/lib/validations/managed-profile-handover";
export const POST = apiHandler(async (request: NextRequest) => {
// Refuses to preview over a live session — see `claimGate`.
const gate = await claimGate(
request,
"preview",
(await getSession()) !== null,
);
if (gate.refusal) return gate.refusal;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = claimPreviewSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const preview = await previewHandover(parsed.data.token);
annotate({
action: { name: "profile_claim.preview" },
meta: { found: preview !== null },
});
if (!preview) return claimInvalid();
return apiSuccess({
displayName: preview.displayName,
expiresAt: preview.expiresAt.toISOString(),
guardians: preview.guardians,
});
});
+150 -7
View File
@@ -1,12 +1,155 @@
/**
* `POST /api/auth/claim` — claim a managed profile through its handover link
* (v1.42, #959). Anonymous by design: the person claiming has no credentials
* yet. The one-time `hlp_` token travels in the body, never in the URL of
* this request, and every failure answers the same 404.
* (v1.42, #959). The person the profile describes sets a username, an email
* and a password, and the profile becomes their own account. No data moves.
*
* Contract stub: answers 501.
* Anonymous by design: the claimant has no credentials yet, and the one-time
* `hlp_` token in the BODY is what authorises the request. The order of the
* work is the registration route's, for the same reasons:
*
* 1. refusals that read no token (`claimGate`: SSO-only, live session, rate
* limit);
* 2. the body, strictly parsed, and the link checked;
* 3. the username and email collision probe, before any expensive work;
* 4. password strength, the breach corpus, the Argon2id hash — outside the
* lock, because none of it depends on the profile;
* 5. the claim itself, one transaction under the managed-profile lock
* (`claimManagedProfile`), where the token is consumed;
* 6. after the commit: the session, the device record, and a notification
* to each former Guardian on their own channels.
*
* A closed registration does not block a claim: no account is created, the
* link is the door.
*/
import { apiHandler } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { NextRequest } from "next/server";
export const POST = apiHandler(async () => notImplemented());
import { apiHandler } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { recordSignInDevice } from "@/lib/auth/login-alert";
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
import { createSession, getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { annotate } from "@/lib/logging/context";
import { claimGate, claimInvalid } from "@/lib/managed-profiles/claim-gate";
import {
claimManagedProfile,
HandoverError,
previewHandover,
} from "@/lib/managed-profiles/handover";
import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify";
import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
import { claimSchema } from "@/lib/validations/managed-profile-handover";
const taken = () =>
apiError("Username or email already taken", 409, {
errorCode: "profile_claim.taken",
});
export const POST = apiHandler(async (request: NextRequest) => {
// Refuses to claim over a live session — see `claimGate`.
const gate = await claimGate(request, "claim", (await getSession()) !== null);
if (gate.refusal) return gate.refusal;
const ip = gate.ip ?? "unknown";
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = claimSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const { token, username, email, password } = parsed.data;
// The link first. Without this the collision probe below would answer 409
// to anybody, token or not, and the claim route would be a username and
// email oracle that registration being closed does not close. With it, only
// the holder of a live link learns that a name is taken. The claim re-checks
// everything under the lock; this read decides nothing on its own.
if (!(await previewHandover(token))) return claimInvalid();
// Before the strength check, the breach lookup and the hash, exactly as the
// registration route orders it (its comment carries the timing argument).
// The profile's own row never collides here: its username is the generated
// `managed-…` one, which no chosen name may start with, and it has no email.
const [existingEmail, existingUsername] = await Promise.all([
prisma.user.findUnique({ where: { email }, select: { id: true } }),
prisma.user.findUnique({ where: { username }, select: { id: true } }),
]);
if (existingEmail || existingUsername) return taken();
const locale = await resolveServerLocale({ request });
const strength = checkPasswordStrength(password, [username, email], locale);
if (!strength.isAcceptable) {
return apiError(
strength.feedback[0] || "Password too weak (score < 3)",
422,
);
}
const breach = await checkPasswordBreachIfEnabled(password);
if (breach?.breached) {
return apiError(
getServerTranslator(locale).t("auth.passwordBreached"),
422,
);
}
const passwordHash = await hashPassword(password);
let claimed;
try {
claimed = await claimManagedProfile({
rawToken: token,
username,
email,
passwordHash,
ipAddress: ip,
});
} catch (error) {
if (error instanceof HandoverError) {
annotate({
action: { name: "profile_claim.refused" },
meta: { reason: error.code },
});
return error.code === "taken" ? taken() : claimInvalid();
}
throw error;
}
// The new owner starts with onboarding owed: the claim reset it, because
// the disclaimer and the setup were a Guardian's answers, not theirs.
const ua = request.headers.get("user-agent");
await createSession(claimed.profileId, true, ip, ua);
void recordSignInDevice({
userId: claimed.profileId,
ip,
userAgent: ua,
alertOnNew: false,
});
await auditLog("auth.claim", {
userId: claimed.profileId,
actorUserId: null,
ipAddress: ip,
details: { method: "password" },
});
notifyGuardiansOfHandover(
"claimed",
claimed.displayName ?? claimed.username,
claimed.guardians,
);
annotate({
action: { name: "profile_claim.complete" },
meta: { guardians: claimed.guardians.length },
});
return apiSuccess(
{ userId: claimed.profileId, username: claimed.username },
201,
);
});
@@ -1,21 +1,186 @@
/**
* `POST /api/managed-profiles/[id]/handover` — mint the one-time link that
* hands a managed profile over to the person it describes, with a proposal
* per guardian; `DELETE` withdraws the open link (v1.42, #959). Cookie-only
* like every act on a managed profile; minting is step-up gated, withdrawing
* is not.
* `/api/managed-profiles/[id]/handover` — the Guardian's side of handing a
* managed profile over to the person it describes (v1.42, #959).
*
* Contract stub: authenticates as the finished route will, then answers 501.
* - `GET` reads whether a link is open, never the link itself (only its hash
* exists). Cookie-only, no step-up: it discloses to a Guardian what they
* already administer.
* - `POST` mints the one-time link with a proposal per Guardian. Step-up
* gated like every act that changes what a managed profile is
* (`requireFreshMfa`), and rate-limited like its siblings.
* - `DELETE` withdraws the open link. Cookie-only and NOT step-up gated:
* taking a link back must never be harder than handing it out.
*
* All three answer the same 404 for "no such profile", "not managed" and "not
* a Guardian of it", so the route is not an enumeration oracle. Bearer tokens
* cannot reach any of them: every gate here resolves the cookie session.
*/
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { NextRequest } from "next/server";
export const POST = apiHandler(async () => {
await requireCookieAuth();
return notImplemented();
});
import {
apiHandler,
MFA_STEP_UP_MAX_AGE_SECONDS,
requireCookieAuth,
requireFreshMfa,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { buildHandoverUrl } from "@/lib/auth/handover-token";
import { isOidcOnly } from "@/lib/auth/oidc";
import { annotate } from "@/lib/logging/context";
import {
createHandover,
HandoverError,
readHandoverStatus,
revokeHandover,
} from "@/lib/managed-profiles/handover";
import { checkRateLimit } from "@/lib/rate-limit";
import { createHandoverSchema } from "@/lib/validations/managed-profile-handover";
export const DELETE = apiHandler(async () => {
await requireCookieAuth();
return notImplemented();
});
type RouteParams = { params: Promise<{ id: string }> };
const notFound = () =>
apiError("Managed profile not found", 404, {
errorCode: "managed_profile.not_found",
});
/**
* v1.42 refuses the handover on a single-sign-on-only instance: the claim
* sets a password, and such an instance admits none. The SSO arm of the claim
* is a separate piece of work.
*/
const oidcOnlyRefusal = () =>
apiError(
"Handing over a profile is not available while this server allows single sign-on only",
403,
{ errorCode: "profile_claim.oidc_only_unsupported" },
);
/** Same ceiling as creating, editing and inviting: ten an hour per caller. */
const CREATE_LIMIT = 10;
const CREATE_WINDOW_MS = 60 * 60 * 1000;
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireCookieAuth();
const { id } = await params;
const status = await readHandoverStatus({
profileId: id,
guardianId: user.id,
});
if (!status) return notFound();
annotate({
action: { name: "managed_profile.handover.read" },
meta: { profile_id: id, open: status.open !== null },
});
return apiSuccess({
available: !isOidcOnly(),
open: status.open
? {
id: status.open.id,
createdAt: status.open.createdAt.toISOString(),
expiresAt: status.open.expiresAt.toISOString(),
createdByYou: status.open.createdByCaller,
}
: null,
});
},
);
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
// Before the id is read and before any lock is taken, as the siblings do:
// the service locks on the id it is handed before deciding the caller is
// not a Guardian of it.
const rateLimit = await checkRateLimit(
`managed-profile:handover:${user.id}`,
CREATE_LIMIT,
CREATE_WINDOW_MS,
);
if (!rateLimit.allowed) {
return apiError("Too many handover links, try again later", 429);
}
if (isOidcOnly()) return oidcOnlyRefusal();
const { id } = await params;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = createHandoverSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
let created;
try {
created = await createHandover({
profileId: id,
guardianId: user.id,
expiresInDays: parsed.data.expiresInDays,
proposals: parsed.data.proposals.map((p) => ({
grantId: p.grantId,
proposal: p.proposal,
})),
});
} catch (error) {
if (error instanceof HandoverError) {
if (error.code === "unknown_guardian") {
return apiError(
"A proposal names somebody who is not a Guardian",
422,
{
errorCode: "managed_profile.handover.unknown_guardian",
},
);
}
return notFound();
}
throw error;
}
annotate({
action: { name: "managed_profile.handover.create" },
meta: {
profile_id: id,
expires_in_days: parsed.data.expiresInDays,
proposals: parsed.data.proposals.length,
},
});
return apiSuccess(
{
id: created.id,
token: created.token,
url: buildHandoverUrl(created.token, request.url),
expiresAt: created.expiresAt.toISOString(),
},
201,
);
},
);
export const DELETE = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireCookieAuth();
const { id } = await params;
let result;
try {
result = await revokeHandover({ profileId: id, guardianId: user.id });
} catch (error) {
if (error instanceof HandoverError) return notFound();
throw error;
}
annotate({
action: { name: "managed_profile.handover.revoke" },
meta: { profile_id: id, revoked: result.revoked },
});
return apiSuccess(result);
},
);