diff --git a/src/__tests__/account-payload-consumer-guard.test.ts b/src/__tests__/account-payload-consumer-guard.test.ts index 011d62988..174f0e199 100644 --- a/src/__tests__/account-payload-consumer-guard.test.ts +++ b/src/__tests__/account-payload-consumer-guard.test.ts @@ -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")), diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 52dcfc7cf..3b22f9c41 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -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 diff --git a/src/app/api/feature-flags/__tests__/route.test.ts b/src/app/api/feature-flags/__tests__/route.test.ts index 5794d542b..d1db1da2e 100644 --- a/src/app/api/feature-flags/__tests__/route.test.ts +++ b/src/app/api/feature-flags/__tests__/route.test.ts @@ -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( + '; 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 }; + }; + expect(Object.values(body.data.assistant).every((v) => v === false)).toBe( + true, + ); + }); }); diff --git a/src/app/api/feature-flags/route.ts b/src/app/api/feature-flags/route.ts index 6001fe84c..237bfc19e 100644 --- a/src/app/api/feature-flags/route.ts +++ b/src/app/api/feature-flags/route.ts @@ -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", '; rel="successor-version"'); return response; }); diff --git a/src/hooks/__tests__/use-ai-capability.test.tsx b/src/hooks/__tests__/use-ai-capability.test.tsx new file mode 100644 index 000000000..fbc7e9bf8 --- /dev/null +++ b/src/hooks/__tests__/use-ai-capability.test.tsx @@ -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 {JSON.stringify({ state, provider })}; +} + +function render(capability: AiCapabilityKey, withClient = true) { + const tree = ; + const html = renderToStaticMarkup( + withClient ? ( + + {tree} + + ) : ( + tree + ), + ); + const json = html.replace(/^|<\/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); + }); +}); diff --git a/src/hooks/use-ai-capability.ts b/src/hooks/use-ai-capability.ts new file mode 100644 index 000000000..b4c13d094 --- /dev/null +++ b/src/hooks/use-ai-capability.ts @@ -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; +} diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 11e67fa72..65096bd96 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -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>; + /** + * 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 { 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, }; diff --git a/src/lib/ai/capabilities/types.ts b/src/lib/ai/capabilities/types.ts index 47dfa4d78..049b7350f 100644 --- a/src/lib/ai/capabilities/types.ts +++ b/src/lib/ai/capabilities/types.ts @@ -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); -}