From d95c48f48d82c0e3c018aaeb44f1ca363a80a453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 02:28:29 +0200 Subject: [PATCH] feat: classify managed record settings - Inventory every Settings destination as an explicit access class\n- Fail closed for unknown deep-link destinations --- src/lib/record-settings/classification.ts | 93 +++++++++++++++++++ src/lib/record-settings/index.ts | 7 ++ .../record-settings-authorization.test.ts | 16 ++++ 3 files changed, 116 insertions(+) create mode 100644 src/lib/record-settings/classification.ts diff --git a/src/lib/record-settings/classification.ts b/src/lib/record-settings/classification.ts new file mode 100644 index 000000000..8c613c52f --- /dev/null +++ b/src/lib/record-settings/classification.ts @@ -0,0 +1,93 @@ +import type { SettingsSectionSlug } from "@/components/settings/section-slugs"; + +export type SettingsDestinationKind = + | "personal" + | "managed-guardian" + | "adult-shared-unavailable" + | "unavailable"; + +export interface SettingsDestinationClassification { + kind: SettingsDestinationKind; + guardianWritable: boolean; +} + +const PERSONAL: SettingsDestinationClassification = { + kind: "personal", + guardianWritable: false, +}; + +const MANAGED_GUARDIAN: SettingsDestinationClassification = { + kind: "managed-guardian", + guardianWritable: true, +}; + +const MANAGED_GUARDIAN_STATUS: SettingsDestinationClassification = { + kind: "managed-guardian", + guardianWritable: false, +}; + +const ADULT_SHARED_UNAVAILABLE: SettingsDestinationClassification = { + kind: "adult-shared-unavailable", + guardianWritable: false, +}; + +const UNAVAILABLE: SettingsDestinationClassification = { + kind: "unavailable", + guardianWritable: false, +}; + +/** + * The complete Settings route inventory. `satisfies Record` makes adding a new destination a compile error until its record + * classification is reviewed. Unknown destinations resolve to unavailable so + * direct links fail closed rather than inheriting a neighbouring category. + */ +export const SETTINGS_DESTINATION_INVENTORY = { + account: MANAGED_GUARDIAN, + security: PERSONAL, + access: PERSONAL, + modules: MANAGED_GUARDIAN, + integrations: MANAGED_GUARDIAN_STATUS, + sources: UNAVAILABLE, + notifications: MANAGED_GUARDIAN, + layout: ADULT_SHARED_UNAVAILABLE, + dashboard: ADULT_SHARED_UNAVAILABLE, + insights: MANAGED_GUARDIAN, + medications: ADULT_SHARED_UNAVAILABLE, + mood: ADULT_SHARED_UNAVAILABLE, + labs: ADULT_SHARED_UNAVAILABLE, + illness: ADULT_SHARED_UNAVAILABLE, + environment: UNAVAILABLE, + anamnesis: ADULT_SHARED_UNAVAILABLE, + score: ADULT_SHARED_UNAVAILABLE, + vorsorge: ADULT_SHARED_UNAVAILABLE, + thresholds: MANAGED_GUARDIAN, + ai: UNAVAILABLE, + coach: MANAGED_GUARDIAN, + api: PERSONAL, + mcp: PERSONAL, + gesundheitsakte: PERSONAL, + sharing: UNAVAILABLE, + export: PERSONAL, + advanced: PERSONAL, + privacy: PERSONAL, + about: PERSONAL, +} as const satisfies Record; + +export function classifySettingsDestination( + destination: string, +): SettingsDestinationClassification { + return ( + SETTINGS_DESTINATION_INVENTORY[ + destination as SettingsSectionSlug + ] ?? UNAVAILABLE + ); +} + +export function isGuardianSettingsWriteAllowed(destination: string): boolean { + const classification = classifySettingsDestination(destination); + return ( + classification.kind === "managed-guardian" && + classification.guardianWritable + ); +} diff --git a/src/lib/record-settings/index.ts b/src/lib/record-settings/index.ts index 025afd665..3212a6c57 100644 --- a/src/lib/record-settings/index.ts +++ b/src/lib/record-settings/index.ts @@ -4,3 +4,10 @@ export { type RecordSettingsAccess, } from "./access"; export { toRecordSettingsDto, type RecordSettingsDto } from "./dto"; +export { + classifySettingsDestination, + isGuardianSettingsWriteAllowed, + SETTINGS_DESTINATION_INVENTORY, + type SettingsDestinationClassification, + type SettingsDestinationKind, +} from "./classification"; diff --git a/tests/integration/record-settings-authorization.test.ts b/tests/integration/record-settings-authorization.test.ts index 15eaaf24c..838bcfc6b 100644 --- a/tests/integration/record-settings-authorization.test.ts +++ b/tests/integration/record-settings-authorization.test.ts @@ -4,6 +4,10 @@ import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { resolveGuardianRecordSettingsAccess } from "@/lib/record-settings/access"; +import { + classifySettingsDestination, + SETTINGS_DESTINATION_INVENTORY, +} from "@/lib/record-settings/classification"; const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments); @@ -32,4 +36,16 @@ describe("record settings authorization", () => { expect(recordSettingsRoute).toContain("requireGuardianAuth"); expect(recordSettingsRoute).toContain("toRecordSettingsDto"); }); + + it("keeps settings classification exhaustive and fail closed", () => { + expect(Object.keys(SETTINGS_DESTINATION_INVENTORY)).not.toHaveLength(0); + expect(classifySettingsDestination("integrations")).toMatchObject({ + kind: "managed-guardian", + guardianWritable: false, + }); + expect(classifySettingsDestination("ai").kind).toBe("unavailable"); + expect(classifySettingsDestination("unknown-settings-path").kind).toBe( + "unavailable", + ); + }); });