From 009de7cb5f01d78a45eb4d685c90fcf3d9a803b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 02:26:44 +0200 Subject: [PATCH] feat: add record keyed settings - Separate managed record settings from actor identity\n- Limit integration reads to the active managed record --- .../api/record-settings/integrations/route.ts | 51 +++++++++++++++++ src/app/api/record-settings/route.ts | 35 ++++++++++++ src/lib/query-keys/index.ts | 4 ++ src/lib/query-keys/record-settings.ts | 15 +++++ src/lib/record-settings/access.ts | 55 +++++++++++++++++++ src/lib/record-settings/dto.ts | 32 +++++++++++ src/lib/record-settings/index.ts | 6 ++ 7 files changed, 198 insertions(+) create mode 100644 src/app/api/record-settings/integrations/route.ts create mode 100644 src/app/api/record-settings/route.ts create mode 100644 src/lib/query-keys/record-settings.ts create mode 100644 src/lib/record-settings/access.ts create mode 100644 src/lib/record-settings/dto.ts create mode 100644 src/lib/record-settings/index.ts diff --git a/src/app/api/record-settings/integrations/route.ts b/src/app/api/record-settings/integrations/route.ts new file mode 100644 index 000000000..79fe386c6 --- /dev/null +++ b/src/app/api/record-settings/integrations/route.ts @@ -0,0 +1,51 @@ +import { + apiHandler, + requireGuardianAuth, + SharingAccessDeniedError, +} from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { + getIntegrationStatus, + type IntegrationKey, +} from "@/lib/integrations/status"; +import { annotate } from "@/lib/logging/context"; +import { resolveGuardianRecordSettingsAccess } from "@/lib/record-settings"; + +export const dynamic = "force-dynamic"; + +const MANAGED_INTEGRATIONS: readonly IntegrationKey[] = [ + "withings", + "whoop", + "fitbit", + "nightscout", + "polar", + "oura", + "google-health", + "strava", +]; + +/** + * A managed profile may disclose whether its existing integrations are + * connected, but this endpoint deliberately has no control or credential + * projection. OAuth and provider state cannot safely carry a record selector. + */ +export const GET = apiHandler(async () => { + const context = await requireGuardianAuth(); + const access = resolveGuardianRecordSettingsAccess(context); + if (!access) throw new SharingAccessDeniedError(); + const integrations = await Promise.all( + MANAGED_INTEGRATIONS.map(async (integration) => { + const status = await getIntegrationStatus(access.recordId, integration); + return { + integration: status.integration, + state: status.state, + lastSuccessAt: status.lastSuccessAt, + lastAttemptAt: status.lastAttemptAt, + }; + }), + ); + + annotate({ action: { name: "record-settings.integrations.get" } }); + + return apiSuccess({ recordId: access.recordId, integrations }); +}); diff --git a/src/app/api/record-settings/route.ts b/src/app/api/record-settings/route.ts new file mode 100644 index 000000000..9c18b08e8 --- /dev/null +++ b/src/app/api/record-settings/route.ts @@ -0,0 +1,35 @@ +import { + apiHandler, + requireGuardianAuth, + SharingAccessDeniedError, +} from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { + resolveGuardianRecordSettingsAccess, + toRecordSettingsDto, +} from "@/lib/record-settings"; + +export const dynamic = "force-dynamic"; + +/** + * The active managed record's configuration identity. This is intentionally a + * distinct record DTO rather than another actor field on `/api/auth/me`. + */ +export const GET = apiHandler(async () => { + const context = await requireGuardianAuth(); + const access = resolveGuardianRecordSettingsAccess(context); + if (!access) throw new SharingAccessDeniedError(); + + annotate({ action: { name: "record-settings.get" } }); + + return apiSuccess( + toRecordSettingsDto({ + id: access.recordId, + name: context.user.displayName ?? context.user.username, + locale: context.user.locale, + timezone: context.user.timezone, + recordKind: "managed", + }), + ); +}); diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 0085b1415..7dd9aec45 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -35,6 +35,7 @@ import { medicationKeys } from "./medications"; import { mentalHealthKeys } from "./mental-health"; import { moodKeys } from "./mood"; import { nutrientKeys } from "./nutrients"; +import { recordSettingsKeys } from "./record-settings"; import { settingsKeys } from "./settings"; import { sharingKeys } from "./sharing"; import { workoutKeys } from "./workouts"; @@ -63,9 +64,12 @@ export const queryKeys = { ...documentKeys, ...customMetrics, ...nutrientKeys, + ...recordSettingsKeys, ...sharingKeys, }; +export { recordSettingsKeys } from "./record-settings"; + /** * v1.36.0 — every read a grant transition can make stale. * diff --git a/src/lib/query-keys/record-settings.ts b/src/lib/query-keys/record-settings.ts new file mode 100644 index 000000000..3f47c26d3 --- /dev/null +++ b/src/lib/query-keys/record-settings.ts @@ -0,0 +1,15 @@ +/** + * Record configuration has its own cache family because it follows the active + * health record, while `authMe()` remains the person at the keyboard. Keeping + * the record id in every member prevents an actor cache cell from being read + * or invalidated as though it belonged to a switched record. + */ +export const recordSettingsKeys = { + root: () => ["record-settings"] as const, + detail: (recordId: string) => + ["record-settings", recordId, "detail"] as const, + integrations: (recordId: string) => + ["record-settings", recordId, "integrations"] as const, + profile: (recordId: string) => + ["record-settings", recordId, "profile"] as const, +}; diff --git a/src/lib/record-settings/access.ts b/src/lib/record-settings/access.ts new file mode 100644 index 000000000..ba1f8c79b --- /dev/null +++ b/src/lib/record-settings/access.ts @@ -0,0 +1,55 @@ +export type RecordSettingsRelationship = + | "guardian" + | "self" + | "adult-manager"; + +export interface RecordSettingsAccess { + actorId: string; + recordId: string; + recordKind: "managed" | "self" | "shared"; + relationship: RecordSettingsRelationship; +} + +/** + * Assert a record-settings capability that is deliberately narrower than a + * general MANAGE grant. A MANAGE grant on an adult's ordinary record is not a + * route into their identity or configuration. + */ +export function assertRecordSettingsAccess( + access: RecordSettingsAccess, + destination: "guardian", +): void { + if ( + destination === "guardian" && + (access.recordKind !== "managed" || access.relationship !== "guardian") + ) { + throw new Error("Guardian configuration is unavailable for this record"); + } +} + +/** + * Convert the resolver's authenticated record into the explicit settings + * boundary. The resolver authenticates the active grant; this function keeps + * the managed-record marker and relationship requirement visible at every + * record-settings route. + */ +export function resolveGuardianRecordSettingsAccess(context: { + actor: { id: string }; + grantId: string | null; + user: { id: string; managedProfileAt: Date | null }; +}): RecordSettingsAccess | null { + const access: RecordSettingsAccess = { + actorId: context.actor.id, + recordId: context.user.id, + recordKind: context.user.managedProfileAt ? "managed" : "self", + relationship: context.grantId ? "guardian" : "self", + }; + + try { + assertRecordSettingsAccess(access, "guardian"); + } catch { + return null; + } + + return access; +} diff --git a/src/lib/record-settings/dto.ts b/src/lib/record-settings/dto.ts new file mode 100644 index 000000000..678c49c7b --- /dev/null +++ b/src/lib/record-settings/dto.ts @@ -0,0 +1,32 @@ +export interface RecordSettingsDto { + record: { + id: string; + displayName: string; + locale: string | null; + timezone: string; + kind: "managed"; + }; +} + +/** + * The record-settings payload intentionally names the record it describes. + * It is never an extension of `/api/auth/me`, which remains the actor's + * identity payload while the browser is switched into another record. + */ +export function toRecordSettingsDto(source: { + id: string; + name: string | null; + locale: string | null; + timezone: string; + recordKind: "managed"; +}): RecordSettingsDto { + return { + record: { + id: source.id, + displayName: source.name ?? "", + locale: source.locale, + timezone: source.timezone, + kind: source.recordKind, + }, + }; +} diff --git a/src/lib/record-settings/index.ts b/src/lib/record-settings/index.ts new file mode 100644 index 000000000..025afd665 --- /dev/null +++ b/src/lib/record-settings/index.ts @@ -0,0 +1,6 @@ +export { + assertRecordSettingsAccess, + resolveGuardianRecordSettingsAccess, + type RecordSettingsAccess, +} from "./access"; +export { toRecordSettingsDto, type RecordSettingsDto } from "./dto";