Files
HealthLog/src/hooks/use-ai-capability.ts
T
Marc-André Bombeck e2b7e7350e fix(consent): mint AI consent receipts only on an explicit agreement
The AI settings page posted to /api/consent/ai/web on every mount and the
server minted an ai_full receipt for it, so an account that had agreed to
nothing sent its health data to the operator's provider from background
jobs. Native builds synced a receipt the same way on every launch.

- Both grant routes now require `confirm: true`; anything else writes
  nothing and answers 400 consent.ai.confirmRequired.
- Remove the mount-time heal. A consent card names the operator's service
  and the data that would go there, with one Agree button, in Settings > AI
  and on the Insights overview while AI is closed by the missing consent
  alone.
- Publish the standing consent as `ai.consent` on /api/auth/me (status,
  since, recipients); the settings card renders active-since and the
  withdraw control from it.
- Existing receipts stay valid.
- Guard that no client grants outside a click handler, route tests for the
  confirmation, and an integration test that a fresh operator-key account
  gets no background AI text until it agrees.
2026-10-16 17:11:04 +02:00

172 lines
6.2 KiB
TypeScript

"use client";
import { useAuth } from "@/hooks/use-auth";
import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
import type {
AiCapabilityKey,
AiCapabilityState,
AiConsentState,
AiProviderState,
} from "@/lib/ai/capabilities/types";
import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout";
import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve";
import type { AiUsageState } from "@/lib/ai/usage-level";
/**
* The web reader for the `ai` block on `GET /api/auth/me`.
*
* The server resolves every AI capability for the record this browser is
* inside, from every layer that can say no, and publishes the answer with the
* reason. This hook hands that answer to a surface and adds exactly one rule
* of its own: until the answer is known, a capability is unavailable. While
* `/me` is loading, when no query client is mounted (an isolated
* presentational render), or when the payload carries no well-formed block,
* the hook answers `check_failed`, so no AI surface paints and no model
* request fires on first render only to be refused. Failing open was how AI
* chrome used to flash and fire refusals before the switch set arrived.
*
* Never recompute a capability here or in a component; branch on
* `available`, and on `reason` only to say why.
*/
const UNKNOWN: AiCapabilityState = Object.freeze({
available: false,
reason: "check_failed",
onDeviceAllowed: false,
});
const NO_PROVIDER_STATE: AiProviderState = Object.freeze({
configured: false,
managedBy: null,
canConfigure: false,
responseTimeoutMs: PROVIDER_DEFAULT_TIMEOUT_MS,
});
/** One capability, for the record this browser is inside. */
export function useAiCapability(key: AiCapabilityKey): AiCapabilityState {
const hasClient = useQueryClientMounted();
if (!hasClient) return UNKNOWN;
// The branch is stable for the component's lifetime (a provider is either
// mounted around it or not), so the conditional hook call is safe.
// eslint-disable-next-line react-hooks/rules-of-hooks
return useAiCapabilityInner(key);
}
function useAiCapabilityInner(key: AiCapabilityKey): AiCapabilityState {
const { user } = useAuth();
return user?.ai?.capabilities?.[key] ?? UNKNOWN;
}
/** The account's provider state: configured, managed by whom, configurable here. */
export function useAiProviderState(): AiProviderState {
const hasClient = useQueryClientMounted();
if (!hasClient) return NO_PROVIDER_STATE;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useAiProviderStateInner();
}
function useAiProviderStateInner(): AiProviderState {
const { user } = useAuth();
return user?.ai?.provider ?? NO_PROVIDER_STATE;
}
/**
* v1.41 — the Coach's thinking depth, resolved on the server (`coachReasoning`
* on `GET /api/auth/me`), or `null` while it is not known: `/me` loading, no
* query client, or a server that does not publish it. Render the settings
* from it; never recompute the cap or the switch.
*/
export function useCoachReasoning(): CoachReasoningState | null {
const hasClient = useQueryClientMounted();
if (!hasClient) return null;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useCoachReasoningInner();
}
function useCoachReasoningInner(): CoachReasoningState | null {
const { user } = useAuth();
return user?.coachReasoning ?? null;
}
/**
* v1.43 (#1217) — the record's AI usage level (`ai.usage` on
* `GET /api/auth/me`): the level in effect, the person's choice, the
* operator's bounds, the three options with their estimates. `null` while it
* is not known or on a server that does not publish it; the setting is then
* not shown. Render from it; never compute an estimate or an allowance here.
*/
export function useAiUsage(): AiUsageState | null {
const hasClient = useQueryClientMounted();
if (!hasClient) return null;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useAiUsageInner();
}
function useAiUsageInner(): AiUsageState | null {
const { user } = useAuth();
return user?.ai?.usage ?? null;
}
/**
* v1.43.2 — the standing AI consent (`ai.consent` on `GET /api/auth/me`):
* given (and since when), needed (and for which services), or not needed.
* `null` while it is not known; a consent surface then says nothing rather
* than misreport the person's decision. Render from it; never decide here
* whether consent is needed.
*/
export function useAiConsent(): AiConsentState | null {
const hasClient = useQueryClientMounted();
if (!hasClient) return null;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useAiConsentInner();
}
function useAiConsentInner(): AiConsentState | null {
const { user } = useAuth();
return user?.ai?.consent ?? null;
}
/**
* The server's answer for one capability, or `null` while there is none yet
* (`/me` loading, no signed-in account, no query client). For the few places
* that act on an unavailable capability rather than just hiding: a Coach page
* that sends its visitor elsewhere must not do so on the loading frame, when
* `useAiCapability` already reads unavailable.
*/
export function useAiCapabilityAnswer(
key: AiCapabilityKey,
): AiCapabilityState | null {
const hasClient = useQueryClientMounted();
if (!hasClient) return null;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useAiCapabilityAnswerInner(key);
}
function useAiCapabilityAnswerInner(
key: AiCapabilityKey,
): AiCapabilityState | null {
const { user, isLoading } = useAuth();
if (isLoading || !user) return null;
return user.ai?.capabilities?.[key] ?? UNKNOWN;
}
/**
* Every capability at once, or `null` until `/me` has answered. For the one
* surface that explains the whole set (the operator notice in Settings → AI);
* a surface that shows or hides one feature reads `useAiCapability`.
*/
export function useAiCapabilityMap(): Partial<
Record<AiCapabilityKey, AiCapabilityState>
> | null {
const hasClient = useQueryClientMounted();
if (!hasClient) return null;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useAiCapabilityMapInner();
}
function useAiCapabilityMapInner(): Partial<
Record<AiCapabilityKey, AiCapabilityState>
> | null {
const { user } = useAuth();
return user?.ai?.capabilities ?? null;
}