mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
feat(api): say why a module's surfaces are not there, not only that they are not
`GET /api/auth/me` publishes a boolean per module, and three different situations collapse into the same `false`: the record has the module switched off, the active grant's sections do not open it, or the operator switched it off for the whole instance. A client holding the boolean has to guess between them, so the empty state either says nothing or says something that may be untrue -- offering a delegate a switch that is not theirs to reach, or telling an account holder to turn on a module the operator removed. `moduleAccess` carries the reason beside the booleans, which keep their exact meaning: `enabled`, `disabled`, `not_granted`, `unavailable`, resolved outside-in so the reason published is the one nothing further in can change. `modules[key]` stays exactly `moduleAccess[key] === "enabled"` for every key, and the two halves are built independently -- the boolean still runs through `maskModulesToSections` -- so the test of that pairing is an assertion rather than a restatement of a derivation. It is checked on the pure builder across a matrix of grants, records and operator blobs, and again on the real route for a scoped delegate, an unscoped one, and an operator-disabled module. Both ends in one change, because a payload field with no reader is the failure this repository keeps rediscovering. The reader is `ModuleDisabledNotice`, the shared module-off empty state the empty-states audit asks for: it names the reason in the reader's own language and offers the enable control only when the record's own switch is what is off, since neither of the other two is fixable from that page. The nutrients page adopts it and drops its bespoke block, which had been handing every delegate a button that would refuse them. Registered in the OpenAPI registry as `ModuleAccessMap` and carried on the account payload; the native contract gains a field and loses nothing, since a Bearer request carries no switch and never reads `not_granted`.
This commit is contained in:
1 parent
0c46df1413
commit
47a5079cfa
17 files changed
+859
-21
No files matched your search
@@ -34,6 +34,7 @@ import {
|
||||
inviteManagedProfileGuardianSchema,
|
||||
updateManagedProfileSchema,
|
||||
} from "@/lib/validations/managed-profiles";
|
||||
import { moduleAccessMap } from "./profile";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
/**
|
||||
@@ -448,12 +449,13 @@ const accountPayload = z
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
accountAccess: accountAccessBlock,
|
||||
moduleAccess: moduleAccessMap,
|
||||
recordSession: recordSessionState.nullable(),
|
||||
})
|
||||
.meta({
|
||||
id: "AccountPayload",
|
||||
description:
|
||||
"The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. Additional properties are the preference fields this spec does not yet enumerate. Under an active switch the identity and preference fields still describe the CALLER, because display preferences belong to the person at the keyboard rather than to the record they are reading. Two fields are the exception (v1.38.14): `modules` and `cycleTrackingEnabled` describe the ACTIVE RECORD, since every surface they gate shows the record's data — and they are masked to the sections the active grant opens, so a scoped grant reads `false` for a module outside it rather than the record's true state. With no switch — which is every native request, since the Bearer transport carries none — the two are the same account and nothing is masked.",
|
||||
"The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. Additional properties are the preference fields this spec does not yet enumerate. Under an active switch the identity and preference fields still describe the CALLER, because display preferences belong to the person at the keyboard rather than to the record they are reading. Two fields are the exception (v1.38.14): `modules` and `cycleTrackingEnabled` describe the ACTIVE RECORD, since every surface they gate shows the record's data — and they are masked to the sections the active grant opens, so a scoped grant reads `false` for a module outside it rather than the record's true state. With no switch — which is every native request, since the Bearer transport carries none — the two are the same account and nothing is masked. `moduleAccess` says WHY each module is or is not there — the record's own switch, the edge of the grant, or the operator's instance-wide one — beside the `modules` booleans it never contradicts.",
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -87,6 +87,32 @@ const moduleMapResolved = z
|
||||
"Fully-resolved module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; `coach` mirrors the resolved per-user opt-out + operator master flag. On `GET /api/auth/me` it answers for the RECORD the session is inside rather than for the caller, because every surface it gates shows the record's data, and it is masked to the sections the active grant opens — a module outside them reads `false` rather than the record's true state. With no switch — which is every native request, since the Bearer transport carries none — the two are the same account, nothing is masked and the payload is unchanged.",
|
||||
});
|
||||
|
||||
/**
|
||||
* The same answer as `ModuleMap`, with the reason attached.
|
||||
*
|
||||
* Additive and beside the booleans, which keep their meaning: for every key
|
||||
* `modules[key]` is exactly `moduleAccess[key] === "enabled"`. A client that
|
||||
* only gates a surface keeps reading the boolean; a client that has to TELL
|
||||
* somebody why a surface is missing reads this, because the boolean collapses
|
||||
* three different situations into one `false`.
|
||||
*/
|
||||
export const moduleAccessMap = z
|
||||
.object(
|
||||
Object.fromEntries(
|
||||
MODULE_KEYS.map((k) => [
|
||||
k,
|
||||
z
|
||||
.enum(["enabled", "disabled", "not_granted", "unavailable"])
|
||||
.describe(`Why the "${k}" module is or is not available here.`),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.meta({
|
||||
id: "ModuleAccessMap",
|
||||
description:
|
||||
"Why each toggleable module is or is not available for the record this session is inside. `enabled` — the module is on and the client may paint it. `disabled` — the record has it switched off. `not_granted` — the active grant's sections do not open the module's section, which also covers every module that reads across the whole record (achievements, the assistant surfaces, the export, environment, the account's own MCP endpoint) since no scoped grant opens one. `unavailable` — the operator switched the module off for the whole instance. Precedence, highest first: `unavailable` > `not_granted` > `disabled` > `enabled`, so the reason published is the one nothing further in can change. Own-record sessions and unscoped grants never produce `not_granted`. Every key is present, and `modules[key] === (moduleAccess[key] === \"enabled\")` holds for every key — this field adds a reason, never a different gate.",
|
||||
});
|
||||
|
||||
const moduleMapEnvelopeInner = z
|
||||
.object({ modules: moduleMapResolved, updatedAt: updatedAtTokenField })
|
||||
.meta({ id: "ModulesResponse" });
|
||||
|
||||
Reference in new issue
Block a user