mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(ai): publish capabilities on the account payload
GET /api/auth/me gains ai: the state of every capability and the provider block, resolved for the active record and masked to the grant like moduleAccess. The authority comes from the switch the payload already resolved, so a delegate reads not_permitted_for_record and a guardian is limited to the operator's key. useAiCapability is the web reader and fails closed until the payload arrives. GET /api/feature-flags becomes a deprecated projection of the operator switches with a Deprecation header and a link to its successor.
This commit is contained in:
1 parent
7c757520f1
commit
464522f315
8 files changed
+296
-36
No files matched your search
@@ -309,6 +309,23 @@ describe("account payload consumer guard", () => {
|
||||
expect(reads).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("has a reader for the AI capability block that reads the payload, not a translation key", () => {
|
||||
// `ai` is a two-letter name, and the generic sweep above accepts it for
|
||||
// every `settings.ai.*` translation key in the tree, so on its own it
|
||||
// would pass with no reader at all. The block's reader is named here: the
|
||||
// capability hook, which is the one place a surface may read it, reading
|
||||
// both halves off the account payload.
|
||||
expect(fields).toContain("ai");
|
||||
const hook = stripComments(
|
||||
readFileSync(join(SRC, "hooks", "use-ai-capability.ts"), "utf8"),
|
||||
);
|
||||
const reads = [
|
||||
/\buser\?\.ai\?\.capabilities\b/,
|
||||
/\buser\?\.ai\?\.provider\b/,
|
||||
].filter((read) => read.test(hook));
|
||||
expect(reads.length).toBe(2);
|
||||
});
|
||||
|
||||
it("has non-zero entry and active-record readers for additive access fields", () => {
|
||||
const entrySources = ACCOUNT_ENTRY_CONSUMERS.map((file) =>
|
||||
stripComments(readFileSync(file, "utf8")),
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
sectionsOpen,
|
||||
} from "@/lib/sharing/module-disclosure";
|
||||
import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
|
||||
import { loadAiCapabilities } from "@/lib/ai/capabilities/load";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -115,12 +116,32 @@ export const GET = apiHandler(async () => {
|
||||
// no switch at all, and the reason the own-record payload is untouched.
|
||||
const sections = accountAccess.active?.sections ?? null;
|
||||
|
||||
// Who may put a model to work on that record, stated from the switch this
|
||||
// payload already resolved rather than read back off the request: this is an
|
||||
// actor surface, so the request carries no record authority of its own.
|
||||
// One's own record is the owner's; a managed profile is its guardian's,
|
||||
// limited to the operator's key; anybody else's is a delegate's, and a
|
||||
// delegate starts no AI work on a record that is not theirs.
|
||||
const recordKind = accountAccess.active?.recordKind ?? "self";
|
||||
const aiAuthority = {
|
||||
origin:
|
||||
recordKind === "self"
|
||||
? ("owner" as const)
|
||||
: recordKind === "managed"
|
||||
? ("guardian" as const)
|
||||
: ("delegate" as const),
|
||||
recordUserId: recordId,
|
||||
actorUserId: user.id,
|
||||
grantId: null,
|
||||
};
|
||||
|
||||
const [
|
||||
record,
|
||||
cycleProfile,
|
||||
resolvedModules,
|
||||
moduleAvailability,
|
||||
onboardingRow,
|
||||
ai,
|
||||
] = await Promise.all([
|
||||
// The actor's own row is already in hand; a switched session needs the
|
||||
// record's `gender`, which is the column the cycle gate derives from.
|
||||
@@ -171,6 +192,19 @@ export const GET = apiHandler(async () => {
|
||||
// keyed by record already, and a delegate seeing the record's own setup
|
||||
// state is what makes the dashboard they are looking at coherent.
|
||||
loadOnboardingRecordRow(prisma, recordId),
|
||||
// v1.39 — the AI capabilities for the RECORD, resolved on the server from
|
||||
// every layer that can say no: the operator's switches, the record's
|
||||
// modules (masked to the grant exactly like `moduleAccess`), the
|
||||
// provider-work authority, provider presence and consent. The module map
|
||||
// and the operator availability it needs are the memoised reads above, so
|
||||
// the extra cost is the provider presence (two point reads) and one read
|
||||
// of the active consent kinds.
|
||||
loadAiCapabilities({
|
||||
recordId,
|
||||
authority: aiAuthority,
|
||||
sections,
|
||||
recordKind,
|
||||
}),
|
||||
]);
|
||||
|
||||
// The masking step, and the one place the record scoping is narrowed rather
|
||||
@@ -350,6 +384,14 @@ export const GET = apiHandler(async () => {
|
||||
// adds a reason without moving a gate; empty states read it to say the
|
||||
// honest thing rather than offering a switch the reader cannot reach.
|
||||
moduleAccess,
|
||||
// v1.39 — which AI capabilities this record has, and why not when it has
|
||||
// none: `capabilities` maps every capability key to `{ available, reason,
|
||||
// onDeviceAllowed }` (reason is the outermost layer that said no), and
|
||||
// `provider` says whether a provider is configured, who manages it, and
|
||||
// whether the person in front of the screen may set one up. Resolved for
|
||||
// the RECORD and masked to the grant, like `modules`. Clients render from
|
||||
// it and never recompute it; data never depends on it.
|
||||
ai,
|
||||
// v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu,
|
||||
// `active` is the record this session is inside (null when it is in its
|
||||
// own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds
|
||||
|
||||
@@ -145,4 +145,27 @@ describe("GET /api/feature-flags", () => {
|
||||
const res = await GET(req());
|
||||
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
|
||||
});
|
||||
|
||||
it("announces itself deprecated and names its successor", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
FIND.mockResolvedValue(null);
|
||||
const res = await GET(req());
|
||||
expect(res.headers.get("Deprecation")).toBe("true");
|
||||
expect(res.headers.get("Link")).toBe(
|
||||
'</api/auth/me>; rel="successor-version"',
|
||||
);
|
||||
});
|
||||
|
||||
it("answers every switch off when the switches cannot be read", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
FIND.mockRejectedValue(new Error("db down"));
|
||||
const res = await GET(req());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { assistant: Record<string, boolean> };
|
||||
};
|
||||
expect(Object.values(body.data.assistant).every((v) => v === false)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,38 +1,21 @@
|
||||
/**
|
||||
* `GET /api/feature-flags` — operator-side assistant flag matrix.
|
||||
* `GET /api/feature-flags` — the operator's assistant switches. DEPRECATED.
|
||||
*
|
||||
* Projects `AppSettings.assistant*Enabled` over HTTP so every client
|
||||
* (web React tree + iOS native) reads the same authoritative shape.
|
||||
* The answer a client needs is not the switch set but what the switches, the
|
||||
* record's modules, the provider-work authority, the provider and consent add
|
||||
* up to, per capability. That is `ai` on `GET /api/auth/me`, and it is what
|
||||
* every client reads from this release on. This route stays a projection of
|
||||
* the operator switches alone, for clients that have not moved yet, and is
|
||||
* removed in the first release after the native build that reads `ai` ships:
|
||||
*
|
||||
* Response envelope:
|
||||
* { "data": { "assistant": { "enabled", "coach", "briefing",
|
||||
* "insightStatus", "documentAi" } } }
|
||||
*
|
||||
* {
|
||||
* "data": {
|
||||
* "assistant": {
|
||||
* "enabled": true,
|
||||
* "coach": true,
|
||||
* "briefing": true,
|
||||
* "insightStatus": true,
|
||||
* "correlations": true
|
||||
* }
|
||||
* },
|
||||
* "error": null
|
||||
* }
|
||||
* The master is applied (every sub-switch reads false when it is off), and a
|
||||
* switch read that fails answers every switch off. The response carries
|
||||
* `Deprecation: true` and a `Link` to its successor.
|
||||
*
|
||||
* - `requireActorAuth()` — any logged-in user, including one acting on
|
||||
* somebody else's record. Per-request flag fetches from the iOS native
|
||||
* client always arrive after auth, so the gate matches the rest of the
|
||||
* read-only profile surface; the mode is argued at the call site.
|
||||
* - Master kills every sub-flag in the resolver before the shape
|
||||
* leaves the handler, so callers never have to compose
|
||||
* `master && sub`.
|
||||
* - `Cache-Control: private, max-age=60` — operator toggles flip
|
||||
* rarely; a 60-second per-session cache keeps the cost off the
|
||||
* hot /insights mount path while still propagating an admin
|
||||
* change within a minute.
|
||||
*
|
||||
* Locked per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5
|
||||
* and `.planning/research/v15-assistant-optional.md` Part D.
|
||||
* `Cache-Control: private, max-age=60`: switches flip rarely.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
@@ -53,11 +36,16 @@ export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
// about the DEPLOYMENT, which for this purpose is the caller's side of the
|
||||
// request, not the record's.
|
||||
await requireActorAuth();
|
||||
annotate({ action: { name: "feature-flags.read" } });
|
||||
annotate({
|
||||
action: { name: "feature-flags.read" },
|
||||
meta: { deprecated: true },
|
||||
});
|
||||
|
||||
const assistant = await getAssistantFlags();
|
||||
|
||||
const response = apiSuccess({ assistant });
|
||||
response.headers.set("Cache-Control", "private, max-age=60");
|
||||
response.headers.set("Deprecation", "true");
|
||||
response.headers.set("Link", '</api/auth/me>; rel="successor-version"');
|
||||
return response;
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* `useAiCapability` hands a surface the server's answer and fails closed until
|
||||
* there is one: loading, no query client, or a payload without the block all
|
||||
* read as unavailable, so no AI surface paints before the answer is known.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
let authUser: unknown = null;
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: authUser }),
|
||||
}));
|
||||
|
||||
import { useAiCapability, useAiProviderState } from "../use-ai-capability";
|
||||
import type { AiCapabilityKey } from "@/lib/ai/capabilities/types";
|
||||
|
||||
function Probe({ capability }: { capability: AiCapabilityKey }) {
|
||||
const state = useAiCapability(capability);
|
||||
const provider = useAiProviderState();
|
||||
return <span>{JSON.stringify({ state, provider })}</span>;
|
||||
}
|
||||
|
||||
function render(capability: AiCapabilityKey, withClient = true) {
|
||||
const tree = <Probe capability={capability} />;
|
||||
const html = renderToStaticMarkup(
|
||||
withClient ? (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{tree}
|
||||
</QueryClientProvider>
|
||||
) : (
|
||||
tree
|
||||
),
|
||||
);
|
||||
const json = html.replace(/^<span>|<\/span>$/g, "").replace(/"/g, '"');
|
||||
return JSON.parse(json) as {
|
||||
state: { available: boolean; reason: string | null };
|
||||
provider: { configured: boolean; canConfigure: boolean };
|
||||
};
|
||||
}
|
||||
|
||||
const UNKNOWN = {
|
||||
available: false,
|
||||
reason: "check_failed",
|
||||
onDeviceAllowed: false,
|
||||
};
|
||||
|
||||
describe("useAiCapability", () => {
|
||||
it("reads the server's answer for the capability", () => {
|
||||
authUser = {
|
||||
ai: {
|
||||
capabilities: {
|
||||
briefing: { available: true, reason: null, onDeviceAllowed: true },
|
||||
coach: {
|
||||
available: false,
|
||||
reason: "user_disabled",
|
||||
onDeviceAllowed: false,
|
||||
},
|
||||
},
|
||||
provider: { configured: true, managedBy: "user", canConfigure: true },
|
||||
},
|
||||
};
|
||||
expect(render("briefing").state).toEqual({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
expect(render("coach").state.reason).toBe("user_disabled");
|
||||
expect(render("coach").provider.configured).toBe(true);
|
||||
});
|
||||
|
||||
it("fails closed while the account payload has not arrived", () => {
|
||||
authUser = null;
|
||||
expect(render("briefing").state).toEqual(UNKNOWN);
|
||||
expect(render("briefing").provider).toEqual({
|
||||
configured: false,
|
||||
managedBy: null,
|
||||
canConfigure: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed for a payload that carries no block", () => {
|
||||
authUser = { ai: null };
|
||||
expect(render("statusText").state).toEqual(UNKNOWN);
|
||||
});
|
||||
|
||||
it("fails closed for a capability the block does not name", () => {
|
||||
authUser = {
|
||||
ai: {
|
||||
capabilities: {},
|
||||
provider: { configured: false, managedBy: null, canConfigure: true },
|
||||
},
|
||||
};
|
||||
expect(render("documentAi").state).toEqual(UNKNOWN);
|
||||
});
|
||||
|
||||
it("fails closed without a query client", () => {
|
||||
authUser = {
|
||||
ai: {
|
||||
capabilities: {
|
||||
briefing: { available: true, reason: null, onDeviceAllowed: true },
|
||||
},
|
||||
provider: { configured: true, managedBy: "user", canConfigure: true },
|
||||
},
|
||||
};
|
||||
expect(render("briefing", false).state).toEqual(UNKNOWN);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
|
||||
import type {
|
||||
AiCapabilityKey,
|
||||
AiCapabilityState,
|
||||
AiProviderState,
|
||||
} from "@/lib/ai/capabilities/types";
|
||||
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format";
|
||||
import { isDateFormatPreference, storeDateFormat } from "@/lib/date-format";
|
||||
import { storeTimezone } from "@/lib/timezone-mirror";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { AiCapabilities } from "@/lib/ai/capabilities/types";
|
||||
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
|
||||
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
|
||||
import type { TourProgress } from "@/lib/onboarding/tour-progress";
|
||||
@@ -227,6 +228,15 @@ export interface AuthUser {
|
||||
* missing key falls back to the boolean.
|
||||
*/
|
||||
moduleAccess?: Partial<Record<ModuleKey, ModuleAccessState>>;
|
||||
/**
|
||||
* v1.39 — the AI capabilities for the record this browser is inside, and
|
||||
* the account's provider state, resolved on the server. Read it through
|
||||
* `useAiCapability`, never directly: the hook is where "not loaded yet" and
|
||||
* "not published by this server" become "unavailable", so nothing fires a
|
||||
* model request or flashes AI chrome before the answer is known. `null`
|
||||
* against a payload that does not carry a well-formed block.
|
||||
*/
|
||||
ai?: AiCapabilities | null;
|
||||
/**
|
||||
* v1.36.0 — account sharing, resolved server-side. `accounts` is the
|
||||
* switcher's menu, `active` is the record this browser is inside (null when
|
||||
@@ -432,6 +442,18 @@ export async function fetchMe(): Promise<AuthUser> {
|
||||
data.moduleAccess && typeof data.moduleAccess === "object"
|
||||
? data.moduleAccess
|
||||
: {},
|
||||
// Coerced to null unless both halves are objects; the capability hook
|
||||
// reads null as "unavailable", which is the safe answer for a block the
|
||||
// server did not send.
|
||||
ai:
|
||||
data.ai &&
|
||||
typeof data.ai === "object" &&
|
||||
data.ai.capabilities &&
|
||||
typeof data.ai.capabilities === "object" &&
|
||||
data.ai.provider &&
|
||||
typeof data.ai.provider === "object"
|
||||
? data.ai
|
||||
: null,
|
||||
accountAccess,
|
||||
accountAccessStatus,
|
||||
};
|
||||
|
||||
@@ -327,8 +327,3 @@ export const AI_RECORD_NOT_PERMITTED_ERROR_CODE = "ai.record.notPermitted";
|
||||
export const AI_PROVIDER_NONE_ERROR_CODE = "ai.provider.none";
|
||||
/** The capability's inputs could not be loaded; the answer failed closed. */
|
||||
export const AI_UNAVAILABLE_ERROR_CODE = "ai.unavailable";
|
||||
|
||||
/** Is this string a capability key? */
|
||||
export function isAiCapabilityKey(value: string): value is AiCapabilityKey {
|
||||
return (AI_CAPABILITY_KEYS as readonly string[]).includes(value);
|
||||
}
|
||||
Reference in new issue
Block a user