mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
97 lines
2.8 KiB
TypeScript
97 lines
2.8 KiB
TypeScript
/**
|
|
* v1.42 (#959) — the bodies the managed-profile handover accepts.
|
|
*
|
|
* Four requests, all `.strict()`: a Guardian minting the link, the anonymous
|
|
* preview and claim on the far side of it, and the new owner's decision about
|
|
* each former Guardian afterwards. The OpenAPI table publishes these exact
|
|
* schemas, so they live here once rather than beside each route.
|
|
*/
|
|
import { z } from "zod/v4";
|
|
|
|
import { HANDOVER_ACCESS_LEVELS } from "@/lib/managed-profiles/handover-access";
|
|
import {
|
|
accountUsernameSchema,
|
|
newAccountPasswordSchema,
|
|
} from "@/lib/validations/auth";
|
|
|
|
/** What a Guardian's access becomes after the handover. */
|
|
export const handoverAccessSchema = z.enum(HANDOVER_ACCESS_LEVELS);
|
|
|
|
/**
|
|
* A grant id as the server minted it (cuid). Bounded so a body cannot carry a
|
|
* megabyte of identifiers into a lookup.
|
|
*/
|
|
const grantIdSchema = z.string().min(1).max(64);
|
|
|
|
/**
|
|
* At most one entry per Guardian, and a profile has a handful. The cap is a
|
|
* body-size floor rather than a product rule.
|
|
*/
|
|
const MAX_GUARDIAN_ENTRIES = 50;
|
|
|
|
/** `POST /api/managed-profiles/{id}/handover`. */
|
|
export const createHandoverSchema = z
|
|
.object({
|
|
// How long the link stays valid, in days: seven unless the Guardian
|
|
// chooses one or fourteen.
|
|
expiresInDays: z
|
|
.union([z.literal(1), z.literal(7), z.literal(14)])
|
|
.default(7),
|
|
proposals: z
|
|
.array(
|
|
z
|
|
.object({ grantId: grantIdSchema, proposal: handoverAccessSchema })
|
|
.strict(),
|
|
)
|
|
.max(MAX_GUARDIAN_ENTRIES)
|
|
.default([])
|
|
.refine(
|
|
(entries) =>
|
|
new Set(entries.map((entry) => entry.grantId)).size ===
|
|
entries.length,
|
|
{ message: "Name each guardian once" },
|
|
),
|
|
})
|
|
.strict();
|
|
|
|
/**
|
|
* The token field of the two anonymous requests. Shape-bounded here only to
|
|
* keep the body small; the `hlp_` shape itself is checked by the service so
|
|
* that a malformed token and an unknown one answer identically.
|
|
*/
|
|
const claimTokenSchema = z.string().min(1).max(80);
|
|
|
|
/** `POST /api/auth/claim/preview`. */
|
|
export const claimPreviewSchema = z
|
|
.object({ token: claimTokenSchema })
|
|
.strict();
|
|
|
|
/** `POST /api/auth/claim`. */
|
|
export const claimSchema = z
|
|
.object({
|
|
token: claimTokenSchema,
|
|
username: accountUsernameSchema,
|
|
email: z.email("Invalid email address").max(254),
|
|
password: newAccountPasswordSchema.max(1024),
|
|
})
|
|
.strict();
|
|
|
|
/** `POST /api/account/handover-decision`. */
|
|
export const handoverDecisionSchema = z
|
|
.object({
|
|
decisions: z
|
|
.array(
|
|
z
|
|
.object({ grantId: grantIdSchema, access: handoverAccessSchema })
|
|
.strict(),
|
|
)
|
|
.max(MAX_GUARDIAN_ENTRIES)
|
|
.refine(
|
|
(entries) =>
|
|
new Set(entries.map((entry) => entry.grantId)).size ===
|
|
entries.length,
|
|
{ message: "Name each guardian once" },
|
|
),
|
|
})
|
|
.strict();
|