mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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 });
|
||||
});
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
assertRecordSettingsAccess,
|
||||
resolveGuardianRecordSettingsAccess,
|
||||
type RecordSettingsAccess,
|
||||
} from "./access";
|
||||
export { toRecordSettingsDto, type RecordSettingsDto } from "./dto";
|
||||
Reference in new issue
Block a user