mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Publishing the active record's module map and cycle flag on the account payload was right for a whole-record grant and too generous for a narrowed one. `accountAccess.active` is set for any live grant, level and scope included, so a delegate scoped to `measurements` read whether the owner tracks their cycle, their screeners, their illness episodes and their supplement intake, and the cycle flag derives from the owner's recorded sex. Both fields are now masked to the sections the active grant names: a closed section answers `false`, which is the answer a module that is off already gives and the answer the browser's route inventory has always acted on. An unscoped grant names the whole record and is unaffected, so the own-record and whole-record payloads are byte-identical to what they were. The masking table lives beside the sharing vocabulary rather than inside it, and runs one way only: no value in it can turn a module on, admit a read or widen a grant.
105 lines
4.3 KiB
TypeScript
105 lines
4.3 KiB
TypeScript
/**
|
|
* What a scoped grant is allowed to learn about a record's configuration.
|
|
*
|
|
* v1.38.14 made `GET /api/auth/me` publish the ACTIVE RECORD's module map and
|
|
* cycle flag rather than the actor's, because every surface those two fields
|
|
* gate shows the record's data. That is right for a whole-record grant and too
|
|
* generous for a narrowed one: `accountAccess.active` is set for any live
|
|
* grant, level and scope included, so a delegate scoped to `measurements`
|
|
* would read whether the owner tracks their cycle, their mental-health
|
|
* screeners, their illness episodes and their supplement intake — configuration
|
|
* the grant does not open, and in the cycle flag's case a derivation of the
|
|
* owner's recorded sex.
|
|
*
|
|
* So the map is masked to the sections the grant names, and the mask is the
|
|
* reason this table can exist beside `scope.ts`'s standing rule that the
|
|
* sharing vocabulary never converts to the module vocabulary. The rule guards
|
|
* a direction: a module the owner enabled must never imply a section the owner
|
|
* shared. Nothing here runs that way. A closed section answers `false` — the
|
|
* same answer the client already gets for a module that is off — and an open
|
|
* one answers whatever the gate resolved, never more. No value in this file
|
|
* can turn a module ON, admit a read, or widen a grant; deleting the table
|
|
* would leak, not lock.
|
|
*
|
|
* `null` means "this module is not one section of a record". Those keys are
|
|
* masked off for every scoped grant, which is the same answer the browser's
|
|
* own route inventory gives them: `/`, `/achievements` and the narrative
|
|
* surfaces read across sections, and a selected-domain grant never opens one.
|
|
*/
|
|
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
|
|
import type { ShareDomain } from "@/lib/sharing/scope";
|
|
|
|
/**
|
|
* The section of a record each module's surfaces read, or `null` when they
|
|
* read across sections.
|
|
*
|
|
* Typed as a total record over {@link MODULE_KEYS}, so a new module cannot
|
|
* ship without an answer here — and the answer a hurried one gives is at worst
|
|
* `null`, which discloses nothing.
|
|
*
|
|
* The section chosen is the one the browser's route inventory already puts the
|
|
* module's page under (`src/lib/navigation/shared-record.ts`), so the chrome a
|
|
* delegate is offered and the configuration they are told about agree.
|
|
*/
|
|
export const MODULE_SHARE_DOMAIN: Readonly<
|
|
Record<ModuleKey, ShareDomain | null>
|
|
> = Object.freeze({
|
|
cycle: "cycle",
|
|
mood: "mind",
|
|
sleep: "measurements",
|
|
glucose: "measurements",
|
|
workouts: "measurements",
|
|
recovery: "measurements",
|
|
labs: "labs",
|
|
illness: "illness",
|
|
// Reads across the record: the achievement engine counts every domain.
|
|
achievements: null,
|
|
// The assistant surfaces narrate the whole record, which is why a scoped
|
|
// grant is never offered them.
|
|
coach: null,
|
|
insights: null,
|
|
medications: "medications",
|
|
// The export assembles every section it is given.
|
|
doctorReport: null,
|
|
// Weather and air quality are context for the record as a whole, not one
|
|
// section of it.
|
|
environment: null,
|
|
// The account's own remote endpoint. Not a section of anybody's record.
|
|
mcp: null,
|
|
inboundDocuments: "documents",
|
|
mentalHealth: "mind",
|
|
nutrients: "measurements",
|
|
// An immunization history is health background, the section `/vaccinations`
|
|
// is presented under.
|
|
vaccinations: "profile",
|
|
});
|
|
|
|
/** Does a grant carrying these sections open this one? `null` is the whole record. */
|
|
export function sectionsOpen(
|
|
sections: readonly ShareDomain[] | null,
|
|
domain: ShareDomain,
|
|
): boolean {
|
|
return sections === null || sections.includes(domain);
|
|
}
|
|
|
|
/**
|
|
* The module map as a grant carrying these sections may read it.
|
|
*
|
|
* `null` sections are the whole record and pass the map through untouched,
|
|
* which keeps the own-record and whole-record payloads byte-identical to what
|
|
* they were. Anything else answers `false` for every key outside the grant.
|
|
*/
|
|
export function maskModulesToSections(
|
|
modules: Record<ModuleKey, boolean>,
|
|
sections: readonly ShareDomain[] | null,
|
|
): Record<ModuleKey, boolean> {
|
|
if (sections === null) return modules;
|
|
const masked = {} as Record<ModuleKey, boolean>;
|
|
for (const key of MODULE_KEYS) {
|
|
const domain = MODULE_SHARE_DOMAIN[key];
|
|
masked[key] =
|
|
domain !== null && sectionsOpen(sections, domain) ? modules[key] : false;
|
|
}
|
|
return masked;
|
|
}
|