mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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.
165 lines
5.6 KiB
TypeScript
165 lines
5.6 KiB
TypeScript
/**
|
|
* The AI capability loader: gathers the resolver's inputs for one record.
|
|
*
|
|
* One read per input, all of them in parallel, all of them memoised per
|
|
* request, and most of them shared with reads the request makes anyway:
|
|
*
|
|
* - operator switches: the `AppSettings` singleton (`loadAssistantSwitches`);
|
|
* - the record's module map and the operator's module availability: the same
|
|
* memoised reads the account payload and every module gate use;
|
|
* - provider presence: the record's credential row and the operator's
|
|
* provider columns (`probeProviderChain`), presence only;
|
|
* - consent: the kinds of the record's active receipts and when the
|
|
* `ai_full` one was signed, one indexed read;
|
|
* - the record's AI usage level (v1.43): the operator's bounds (memoised)
|
|
* and one point read of the record's choice.
|
|
*
|
|
* Any failure resolves to `null`, which the resolver turns into
|
|
* `check_failed` for every capability. Failing closed is safe because no data
|
|
* depends on a capability; failing open would switch AI work on during a
|
|
* database blip.
|
|
*/
|
|
import { prisma } from "@/lib/db";
|
|
import { loadAssistantSwitches } from "@/lib/feature-flags";
|
|
import { getEvent } from "@/lib/logging/context";
|
|
import {
|
|
getOperatorModuleAvailability,
|
|
resolveModuleMap,
|
|
} from "@/lib/modules/gate";
|
|
import { probeProviderChain } from "@/lib/ai/provider";
|
|
import { memoizePerRequest } from "@/lib/request-cache";
|
|
import { buildModuleDisclosure } from "@/lib/sharing/module-disclosure";
|
|
import {
|
|
mayEnqueueProviderWork,
|
|
type ProviderWorkAuthority,
|
|
} from "@/lib/sharing/provider-work-authority";
|
|
import type { ShareDomain } from "@/lib/sharing/scope";
|
|
|
|
import {
|
|
resolveAiBlock,
|
|
type AiCapabilityInputs,
|
|
type AiRecordKind,
|
|
} from "./resolve";
|
|
import type { AiCapabilities } from "./types";
|
|
import { loadAiUsageState, loadRecordUsageLevel } from "./usage";
|
|
|
|
/** Whose record, seen by whom, through which grant. */
|
|
export interface AiCapabilityScope {
|
|
/** The record the capability is resolved for. */
|
|
recordId: string;
|
|
/** The provider-work authority for that record; `null` admits nothing. */
|
|
authority: ProviderWorkAuthority | null;
|
|
/**
|
|
* The sections the active grant opens, `null` for the whole record (one's
|
|
* own record, an unscoped grant, a background job). Masks module access
|
|
* exactly as the account payload masks `moduleAccess`.
|
|
*/
|
|
sections: readonly ShareDomain[] | null;
|
|
recordKind: AiRecordKind;
|
|
}
|
|
|
|
/**
|
|
* The kinds of the record's active consent receipts, and when the active
|
|
* `ai_full` one was signed (the date the consent card shows).
|
|
*/
|
|
function loadActiveConsents(
|
|
recordId: string,
|
|
): Promise<{ kinds: Set<string>; fullSince: Date | null }> {
|
|
return memoizePerRequest(
|
|
`ai-consent-kinds:${recordId}`,
|
|
async () => {
|
|
const rows = await prisma.consentReceipt.findMany({
|
|
where: { userId: recordId, revokedAt: null },
|
|
select: { kind: true, signedAt: true },
|
|
});
|
|
return {
|
|
kinds: new Set(rows.map((row) => row.kind)),
|
|
fullSince: rows.find((row) => row.kind === "ai_full")?.signedAt ?? null,
|
|
};
|
|
},
|
|
{ freshInBackground: true },
|
|
);
|
|
}
|
|
|
|
function scopeKey(scope: AiCapabilityScope): string {
|
|
return [
|
|
scope.recordId,
|
|
scope.authority?.origin ?? "none",
|
|
scope.sections === null ? "*" : [...scope.sections].sort().join(","),
|
|
scope.recordKind,
|
|
].join(":");
|
|
}
|
|
|
|
/**
|
|
* Load the resolver's inputs for one scope, or `null` when any of them could
|
|
* not be read.
|
|
*/
|
|
export function loadAiCapabilityInputs(
|
|
scope: AiCapabilityScope,
|
|
): Promise<AiCapabilityInputs | null> {
|
|
return memoizePerRequest(
|
|
`ai-capability-inputs:${scopeKey(scope)}`,
|
|
async () => {
|
|
try {
|
|
const [
|
|
switches,
|
|
modules,
|
|
availability,
|
|
provider,
|
|
consents,
|
|
usageLevel,
|
|
] = await Promise.all([
|
|
loadAssistantSwitches(),
|
|
resolveModuleMap(scope.recordId),
|
|
getOperatorModuleAvailability(),
|
|
probeProviderChain(scope.recordId, scope.authority),
|
|
loadActiveConsents(scope.recordId),
|
|
loadRecordUsageLevel(scope.recordId),
|
|
]);
|
|
if (switches === null) return null;
|
|
const { moduleAccess } = buildModuleDisclosure(
|
|
modules,
|
|
availability,
|
|
scope.sections,
|
|
);
|
|
return {
|
|
switches,
|
|
moduleAccess,
|
|
providerWorkAdmitted: mayEnqueueProviderWork(scope.authority),
|
|
provider,
|
|
activeConsentKinds: consents.kinds,
|
|
fullConsentSince: consents.fullSince,
|
|
recordKind: scope.recordKind,
|
|
usageLevel,
|
|
};
|
|
} catch {
|
|
getEvent()?.addWarning(
|
|
"Failed to load AI capability inputs; every AI capability is off for this request",
|
|
);
|
|
return null;
|
|
}
|
|
},
|
|
{ freshInBackground: true },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The whole `ai` block for one scope, the record's AI usage level included
|
|
* (v1.43). The measured use and the estimates are published for one's own
|
|
* record only: a delegate sees which level a record runs at, not what its
|
|
* owner spends. A usage read that fails leaves `usage` out rather than
|
|
* failing the block; the capabilities already carry the level they resolved.
|
|
*/
|
|
export async function loadAiCapabilities(
|
|
scope: AiCapabilityScope,
|
|
): Promise<AiCapabilities> {
|
|
const [inputs, usage] = await Promise.all([
|
|
loadAiCapabilityInputs(scope),
|
|
loadAiUsageState(scope.recordId, {
|
|
withMeasurement: scope.recordKind === "self",
|
|
}).catch(() => undefined),
|
|
]);
|
|
const block = resolveAiBlock(inputs);
|
|
return usage ? { ...block, usage } : block;
|
|
}
|