feat: add record keyed settings

- Separate managed record settings from actor identity\n- Limit integration reads to the active managed record
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 02:26:44 +02:00
1 parent 1eb0d22285
commit 009de7cb5f
7 files changed
+198

No files matched your search

@@ -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 });
});
+35
View File
@@ -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",
}),
);
});
+4
View File
@@ -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.
*
+15
View File
@@ -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,
};
+55
View File
@@ -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;
}
+32
View File
@@ -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,
},
};
}
+6
View File
@@ -0,0 +1,6 @@
export {
assertRecordSettingsAccess,
resolveGuardianRecordSettingsAccess,
type RecordSettingsAccess,
} from "./access";
export { toRecordSettingsDto, type RecordSettingsDto } from "./dto";