Files
HealthLog/src/lib/query-keys/sharing.ts
T
Marc-André Bombeck 8617f809f0 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.
2026-10-08 11:18:14 +02:00

103 lines
4.5 KiB
TypeScript

/**
* Query keys — account sharing (v1.36.0).
* Part of the centralized factory; aggregated in `./index.ts`.
*
* Note what these keys deliberately do NOT carry: the acting account. That
* dimension lives on the QueryClient's `queryKeyHashFn`
* (`./record-scope.ts`), which folds it into every entry in the app at once
* — including these. Adding it here as well would partition the same cache
* twice under two rules, which is the two-deciders shape in miniature.
*
* The reads below are all non-delegable by construction: `/api/account/grants`
* and the record-activity feed both resolve through a bare `requireAuth()` and
* refuse while a switch is on. They are still record-scoped like everything
* else, so a session that switches out and back never reads a grant list
* cached under the wrong scope.
*/
export const sharingKeys = {
/**
* Grants in both directions (`GET /api/account/grants`). One key for one
* endpoint: the route answers "who can see my record, and whose can I see"
* in a single payload precisely so no client renders half of it.
*/
accountGrants: () => ["account", "grants"] as const,
/**
* The owner's record-activity feed (`GET /api/account/activity`) — who
* opened their record, and when.
*/
accountActivity: () => ["account", "activity"] as const,
/**
* v1.37.0 — the managed-profile family, as one prefix.
*
* Present so a grant transition can invalidate the whole family at once:
* accepting a Guardian invitation is an ordinary `POST
* /api/account/grants/{id}/accept`, and it moves a row on a roster that
* endpoint knows nothing about. Listing this prefix in `grantDependentKeys`
* is what keeps the two in step without the accept path having to know a
* managed profile exists.
*/
managedProfiles: () => ["managed-profiles"] as const,
/**
* Who looks after one managed profile (`GET
* /api/managed-profiles/{id}/guardians`). Keyed by profile because a
* Guardian may look after more than one, and a single key for all of them
* would be the same-key-different-shape collision the factory exists to
* prevent.
*/
managedProfileGuardians: (profileId: string) =>
["managed-profiles", profileId, "guardians"] as const,
/**
* One managed record's identity (`GET /api/managed-profiles/{id}`). Keyed by
* profile for the same reason the roster is, and under the same prefix so a
* grant transition invalidates it with the rest of the family.
*/
managedProfile: (profileId: string) =>
["managed-profiles", profileId, "profile"] as const,
/**
* v1.42 (#959) — whether a managed profile has an open handover link (`GET
* /api/managed-profiles/{id}/handover`). Under the family prefix so a grant
* transition refreshes it with the rest.
*/
managedProfileHandover: (profileId: string) =>
["managed-profiles", profileId, "handover"] as const,
/**
* v1.42 (#959) — the decision a new owner owes about their former
* Guardians (`GET /api/account/handover-decision`). Under `account` like
* the grant list it changes.
*/
handoverDecision: () => ["account", "handover-decision"] as const,
/**
* v1.42 (#959) — the anonymous preview of a handover link. The token is
* deliberately NOT part of the key: a cache key is a place a credential
* should never be written to, and one claim page reads one link.
*/
profileClaimPreview: () => ["profile-claim", "preview"] as const,
/** Mutation keys — kept in the factory so no bare array reaches a call site. */
accountGrantInvite: () => ["account", "grants", "invite"] as const,
accountGrantAccept: () => ["account", "grants", "accept"] as const,
accountGrantRevoke: () => ["account", "grants", "revoke"] as const,
accountGrantRenounce: () => ["account", "grants", "renounce"] as const,
accountSwitchMutation: () => ["account", "switch"] as const,
managedProfileCreate: () => ["managed-profiles", "create"] as const,
managedProfileUpdate: () => ["managed-profiles", "update"] as const,
managedProfileDelete: () => ["managed-profiles", "delete"] as const,
managedProfileGuardianInvite: () =>
["managed-profiles", "guardian", "invite"] as const,
managedProfileGuardianRemove: () =>
["managed-profiles", "guardian", "remove"] as const,
managedProfileHandoverCreate: () =>
["managed-profiles", "handover", "create"] as const,
managedProfileHandoverRevoke: () =>
["managed-profiles", "handover", "revoke"] as const,
handoverDecide: () => ["account", "handover-decision", "decide"] as const,
profileClaim: () => ["profile-claim", "submit"] as const,
};