From e209db24f4d3f1bc05bfc3b3a72f526a7d0e2434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 01:49:33 +0200 Subject: [PATCH] fix: block delegated provider egress Carry bounded work authority through queues and revalidate it at dispatch.\nResolve shared records without personal credentials. --- src/lib/ai/provider.ts | 55 +++++- src/lib/api-handler.ts | 12 ++ .../__tests__/insight-status-generate.test.ts | 36 +++- .../jobs/insight-status-generate-shared.ts | 19 +- src/lib/jobs/insight-status-generate.ts | 21 +++ src/lib/jobs/period-narrative-shared.ts | 16 ++ src/lib/jobs/period-narrative-warm.ts | 19 +- src/lib/logging/event-builder.ts | 9 + src/lib/logging/types.ts | 4 + src/lib/sharing/provider-work-authority.ts | 133 ++++++++++++++ .../sharing-provider-origin.test.ts | 167 ++++++++++++++++-- 11 files changed, 466 insertions(+), 25 deletions(-) create mode 100644 src/lib/sharing/provider-work-authority.ts diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index a36cf735b..e90e58ba4 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -16,6 +16,11 @@ import { isPublicUrl } from "@/lib/validations/notifications"; import { isLocalAiHostAllowed } from "./local-host-allowlist"; import { parseProviderChain, type ProviderChainType } from "./provider-chain"; import type { ProviderChainResolved } from "./provider-runner"; +import { + providerCredentialPolicy, + providerWorkAuthorityForRecord, + type ProviderCredentialPolicy, +} from "@/lib/sharing/provider-work-authority"; const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes @@ -400,6 +405,12 @@ async function resolveAdminCodexProvider(): Promise { * 4. Nothing configured → NoProvider(). */ export async function resolveProvider(userId: string): Promise { + const policy = providerCredentialPolicy( + providerWorkAuthorityForRecord(userId), + ); + if (policy === "deny") return new NoProvider(); + if (policy === "operator-default") return resolveAdminProvider(); + const userRow = await prisma.user.findUnique({ where: { id: userId }, select: { @@ -459,6 +470,17 @@ export async function resolveProvider(userId: string): Promise { export async function resolveProviderChain( userId: string, ): Promise { + const policy = providerCredentialPolicy( + providerWorkAuthorityForRecord(userId), + ); + if (policy === "deny") return []; + if (policy === "operator-default") { + const provider = await resolveAdminProvider(); + return provider.type === "none" + ? [] + : [{ providerType: "admin-openai", instance: provider }]; + } + const userRow = await prisma.user.findUnique({ where: { id: userId }, select: { @@ -484,6 +506,7 @@ export async function resolveProviderChain( const instance = await resolveProviderForType(entry.providerType, { userId, userRow, + policy, }); if (instance) { resolved.push({ providerType: entry.providerType, instance }); @@ -545,7 +568,10 @@ export interface ProviderCredentialRow { export function userRowHasProviderCredential( row: ProviderCredentialRow, adminKeyConfigured: boolean, + policy: ProviderCredentialPolicy = "personal", ): boolean { + if (policy === "deny") return false; + if (policy === "operator-default") return adminKeyConfigured; const codexConnected = row.codexConnectionStatus === "connected" && !!row.codexAccessTokenEncrypted && @@ -608,6 +634,10 @@ export function userRowHasProviderCredential( export async function hasAnyConfiguredProvider( userId: string, ): Promise { + const policy = providerCredentialPolicy( + providerWorkAuthorityForRecord(userId), + ); + if (policy === "deny") return false; const userRow = await prisma.user.findUnique({ where: { id: userId }, select: { @@ -637,7 +667,13 @@ export async function hasAnyConfiguredProvider( adminCodexAccountIdEncrypted: true, }, }); - if (userRowHasProviderCredential(userRow, !!settings?.adminAiKeyEncrypted)) { + if ( + userRowHasProviderCredential( + userRow, + !!settings?.adminAiKeyEncrypted, + policy, + ) + ) { return true; } // A user with no personal provider can still be served by the operator's @@ -685,7 +721,12 @@ export type ProviderManagedBy = "user" | "local" | "server"; function resolveManagedByFromRow( row: ProviderCredentialRow, adminKeyConfigured: boolean, + policy: ProviderCredentialPolicy = "personal", ): ProviderManagedBy | null { + if (policy === "deny") return null; + if (policy === "operator-default") { + return adminKeyConfigured ? "server" : null; + } const codexConnected = row.codexConnectionStatus === "connected" && !!row.codexAccessTokenEncrypted && @@ -749,6 +790,10 @@ function resolveManagedByFromRow( export async function resolveProviderAvailability( userId: string, ): Promise<{ aiAvailable: boolean; managedBy: ProviderManagedBy | null }> { + const policy = providerCredentialPolicy( + providerWorkAuthorityForRecord(userId), + ); + if (policy === "deny") return { aiAvailable: false, managedBy: null }; const userRow = await prisma.user.findUnique({ where: { id: userId }, select: { @@ -781,6 +826,7 @@ export async function resolveProviderAvailability( const managedBy = resolveManagedByFromRow( userRow, !!settings?.adminAiKeyEncrypted, + policy, ); if (managedBy !== null) return { aiAvailable: true, managedBy }; // The operator's shared central Codex is operator-managed egress ("server"). @@ -808,8 +854,15 @@ async function resolveProviderForType( aiCompatKeyEncrypted: string | null; aiCompatModel: string | null; } | null; + policy: ProviderCredentialPolicy; }, ): Promise { + if (ctx.policy === "deny") return null; + if (ctx.policy === "operator-default") { + return providerType === "admin-openai" + ? await resolveAdminProvider() + : null; + } switch (providerType) { case "codex": { return resolveCodexProvider(ctx.userId); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 8805c10b4..92e0addb9 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -793,6 +793,12 @@ export async function requireRecordAuth( if (carrier.kind === "none") { // Do no harm: without a carrier this is byte-for-byte the pre-v1.36.0 // request, resolved by the same `authenticateCaller` every other mode uses. + getEvent()?.setProviderWorkAuthority({ + origin: "owner", + recordUserId: auth.user.id, + actorUserId: auth.user.id, + grantId: null, + }); return { ...auth, actor: auth.user, grantId: null }; } if (carrier.kind === "misplaced-header") { @@ -944,6 +950,12 @@ async function resolveSwitchedRecord( if (owner.id !== auth.user.id && owner.managedProfileAt === null) { getEvent()?.setDelegatedGenerationSuppressed(); } + getEvent()?.setProviderWorkAuthority({ + origin: owner.managedProfileAt === null ? "delegate" : "guardian", + recordUserId: owner.id, + actorUserId: auth.user.id, + grantId: grant.id, + }); // Fire-and-forget, the `ApiToken.lastUsedAt` posture: the read must not wait // on the bookkeeping, and the bookkeeping failing must not fail the read. void touchGrantUsage(grant.id); diff --git a/src/lib/jobs/__tests__/insight-status-generate.test.ts b/src/lib/jobs/__tests__/insight-status-generate.test.ts index 06ab5ed4e..c2c92d858 100644 --- a/src/lib/jobs/__tests__/insight-status-generate.test.ts +++ b/src/lib/jobs/__tests__/insight-status-generate.test.ts @@ -66,7 +66,17 @@ describe("runInsightStatusGenerate", () => { "medication-compliance": vi.fn(), }; await runInsightStatusGenerate( - { userId: "u1", metric: "weight", locale: "en" }, + { + userId: "u1", + metric: "weight", + locale: "en", + authority: { + origin: "owner", + recordUserId: "u1", + actorUserId: "u1", + grantId: null, + }, + }, generators, ); expect(weight).toHaveBeenCalledWith("u1", { locale: "en", force: true }); @@ -78,7 +88,17 @@ describe("runInsightStatusGenerate", () => { await expect( runInsightStatusGenerate( // eslint-disable-next-line @typescript-eslint/no-explicit-any - { userId: "u1", metric: "nope" as any, locale: "de" }, + { + userId: "u1", + metric: "nope" as any, + locale: "de", + authority: { + origin: "owner", + recordUserId: "u1", + actorUserId: "u1", + grantId: null, + }, + }, // eslint-disable-next-line @typescript-eslint/no-explicit-any {} as any, ), @@ -118,7 +138,17 @@ describe("enqueueStatusGeneration", () => { }); expect(send).toHaveBeenCalledWith( INSIGHT_STATUS_GENERATE_QUEUE, - { userId: "u1", metric: "mood", locale: "en" }, + { + userId: "u1", + metric: "mood", + locale: "en", + authority: { + origin: "system", + recordUserId: "u1", + actorUserId: null, + grantId: null, + }, + }, expect.objectContaining({ singletonKey: "u1:mood" }), ); }); diff --git a/src/lib/jobs/insight-status-generate-shared.ts b/src/lib/jobs/insight-status-generate-shared.ts index 0228bcf4f..fcf9d6669 100644 --- a/src/lib/jobs/insight-status-generate-shared.ts +++ b/src/lib/jobs/insight-status-generate-shared.ts @@ -15,6 +15,11 @@ import type { SupportedLocale } from "@/lib/insights/status-shared"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { annotate } from "@/lib/logging/context"; +import { + mayEnqueueProviderWork, + providerWorkAuthorityForRecord, + type ProviderWorkAuthority, +} from "@/lib/sharing/provider-work-authority"; export const INSIGHT_STATUS_GENERATE_QUEUE = "insight-status-generate"; @@ -91,6 +96,8 @@ export interface InsightStatusGeneratePayload { metric: InsightStatusScope; /** Resolved client locale — the cache key the route reads against. */ locale: SupportedLocale; + /** Durable admission authority, checked again before worker dispatch. */ + authority?: ProviderWorkAuthority; } /** @@ -120,8 +127,16 @@ export interface InsightStatusGeneratePayload { * the requested language. */ export async function enqueueStatusGeneration( - payload: InsightStatusGeneratePayload, + payload: Omit, ): Promise { + const authority = providerWorkAuthorityForRecord(payload.userId); + if (!mayEnqueueProviderWork(authority)) { + annotate({ + action: { name: "insights.status.generate.refused" }, + meta: { metric: payload.metric, reason: "provider_authority" }, + }); + return; + } const boss = getGlobalBoss(); if (!boss) { annotate({ @@ -131,7 +146,7 @@ export async function enqueueStatusGeneration( return; } try { - await boss.send(INSIGHT_STATUS_GENERATE_QUEUE, payload, { + await boss.send(INSIGHT_STATUS_GENERATE_QUEUE, { ...payload, authority }, { singletonKey: `${payload.userId}:${payload.metric}`, // De-dupe within a short window so a polling client doesn't enqueue // a fresh job on every tick while a generation is already running. diff --git a/src/lib/jobs/insight-status-generate.ts b/src/lib/jobs/insight-status-generate.ts index bb20d30c0..9356f8300 100644 --- a/src/lib/jobs/insight-status-generate.ts +++ b/src/lib/jobs/insight-status-generate.ts @@ -56,6 +56,10 @@ import { type InsightStatusMetric, type InsightStatusGeneratePayload, } from "@/lib/jobs/insight-status-generate-shared"; +import { + mayDispatchProviderWork, + withProviderWorkAuthority, +} from "@/lib/sharing/provider-work-authority"; export { INSIGHT_STATUS_GENERATE_QUEUE, @@ -93,6 +97,23 @@ const GENERATORS: Record = { export async function runInsightStatusGenerate( payload: InsightStatusGeneratePayload, generators: Record = GENERATORS, +): Promise { + const authority = payload.authority; + if (!authority || !(await mayDispatchProviderWork(authority))) { + annotate({ + action: { name: "insights.status.generate.refused" }, + meta: { metric: payload.metric, reason: "provider_authority" }, + }); + return; + } + await withProviderWorkAuthority(authority, () => + dispatchInsightStatusGenerate(payload, generators), + ); +} + +async function dispatchInsightStatusGenerate( + payload: InsightStatusGeneratePayload, + generators: Record = GENERATORS, ): Promise { // v1.8.7.1 — a `metric:` scope routes to the generic // HealthKit-metric generator rather than one of the seven specialised diff --git a/src/lib/jobs/period-narrative-shared.ts b/src/lib/jobs/period-narrative-shared.ts index 8c2ee54db..52dad1371 100644 --- a/src/lib/jobs/period-narrative-shared.ts +++ b/src/lib/jobs/period-narrative-shared.ts @@ -13,6 +13,11 @@ */ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { annotate } from "@/lib/logging/context"; +import { + mayEnqueueProviderWork, + providerWorkAuthorityForRecord, + type ProviderWorkAuthority, +} from "@/lib/sharing/provider-work-authority"; import type { NarrativePeriod } from "@/lib/insights/narrative/period-narrative"; import type { Locale } from "@/lib/i18n/config"; @@ -36,6 +41,8 @@ export interface PeriodNarrativePayload { period?: NarrativePeriod; /** Locale to warm; defaults to the app default (`en`) when absent. */ locale?: Locale; + /** Present only for an on-demand record warm. */ + authority?: ProviderWorkAuthority; } /** @@ -50,6 +57,14 @@ export async function enqueueNarrativeWarm(payload: { period: NarrativePeriod; locale: Locale; }): Promise { + const authority = providerWorkAuthorityForRecord(payload.userId); + if (!mayEnqueueProviderWork(authority)) { + annotate({ + action: { name: "insights.narrative.warm.refused" }, + meta: { period: payload.period, reason: "provider_authority" }, + }); + return; + } const boss = getGlobalBoss(); if (!boss) return; try { @@ -59,6 +74,7 @@ export async function enqueueNarrativeWarm(payload: { userId: payload.userId, period: payload.period, locale: payload.locale, + authority, } satisfies PeriodNarrativePayload, { singletonKey: `warm:${payload.userId}:${payload.period}:${payload.locale}`, diff --git a/src/lib/jobs/period-narrative-warm.ts b/src/lib/jobs/period-narrative-warm.ts index 785b10739..f33c9b1a6 100644 --- a/src/lib/jobs/period-narrative-warm.ts +++ b/src/lib/jobs/period-narrative-warm.ts @@ -22,6 +22,10 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { checkRateLimit } from "@/lib/rate-limit"; import { getAssistantFlags } from "@/lib/feature-flags"; +import { + mayDispatchProviderWork, + withProviderWorkAuthority, +} from "@/lib/sharing/provider-work-authority"; import { annotate } from "@/lib/logging/context"; import { generatePeriodNarrative, @@ -254,13 +258,18 @@ function tally( */ export async function warmOneNarrative( payload: PeriodNarrativePayload, + generate: typeof generatePeriodNarrative = generatePeriodNarrative, ): Promise { if (!payload.userId || !payload.period) return null; + const authority = payload.authority; + if (!authority || !(await mayDispatchProviderWork(authority))) return null; const flags = await getAssistantFlags(); if (!flags.briefing && !flags.insightStatus) return null; - return generatePeriodNarrative(payload.userId, { - period: payload.period, - locale: payload.locale ?? defaultLocale, - force: true, - }); + return withProviderWorkAuthority(authority, () => + generate(payload.userId!, { + period: payload.period!, + locale: payload.locale ?? defaultLocale, + force: true, + }), + ); } diff --git a/src/lib/logging/event-builder.ts b/src/lib/logging/event-builder.ts index 528a3c6e3..c85f66ed9 100644 --- a/src/lib/logging/event-builder.ts +++ b/src/lib/logging/event-builder.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import type { WideEvent, LogLevel, EventKind } from "./types"; +import type { ProviderWorkAuthority } from "@/lib/sharing/provider-work-authority"; import { LOG_LEVEL_PRIORITY } from "./types"; import { getDeployContext } from "./config"; import { redactOptional, redactSecrets } from "./redact"; @@ -117,6 +118,14 @@ export class WideEventBuilder { return this; } + setProviderWorkAuthority(authority: ProviderWorkAuthority): this { + this.event.auth = { + ...(this.event.auth ?? {}), + provider_work_authority: authority, + }; + return this; + } + setAction(action: WideEvent["action"]): this { this.event.action = action; return this; diff --git a/src/lib/logging/types.ts b/src/lib/logging/types.ts index add4eeaa2..73d6ff47d 100644 --- a/src/lib/logging/types.ts +++ b/src/lib/logging/types.ts @@ -1,3 +1,5 @@ +import type { ProviderWorkAuthority } from "@/lib/sharing/provider-work-authority"; + /** Log-Levels in aufsteigender Schwere */ export type LogLevel = "debug" | "info" | "warn" | "error"; @@ -76,6 +78,8 @@ export interface WideEvent { * managed profile and every background job is. */ delegated_generation?: "suppressed"; + /** Durable authority copied into provider queue payloads. */ + provider_work_authority?: ProviderWorkAuthority; }; // Business-Daten (was wurde getan) diff --git a/src/lib/sharing/provider-work-authority.ts b/src/lib/sharing/provider-work-authority.ts new file mode 100644 index 000000000..273353403 --- /dev/null +++ b/src/lib/sharing/provider-work-authority.ts @@ -0,0 +1,133 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; +import { findActiveGrant } from "@/lib/sharing/grants"; + +/** + * The authority that admitted provider work for a record. + * + * This envelope carries only durable identifiers. It travels in queue payloads, + * never health content, so a worker can reject stale or delegated work before + * a provider sees the record. + */ +export interface ProviderWorkAuthority { + origin: "owner" | "delegate" | "guardian" | "system"; + recordUserId: string; + actorUserId: string | null; + grantId: string | null; +} + +export type ProviderCredentialPolicy = + | "personal" + | "operator-default" + | "deny"; + +const workerAuthority = new AsyncLocalStorage(); + +export function providerCredentialPolicy( + authority: ProviderWorkAuthority | null, +): ProviderCredentialPolicy { + if (authority === null) return "deny"; + if (authority.origin === "delegate") return "deny"; + if (authority.origin === "guardian") return "operator-default"; + return "personal"; +} + +/** + * Resolve the request or worker authority for exactly one record. + * + * A request that authenticated an actor but did not declare why it may reach a + * different record has no authority to enqueue work for that record. Background + * reconciliation has no actor and is represented explicitly as `system`. + */ +export function providerWorkAuthorityForRecord( + recordUserId: string, +): ProviderWorkAuthority | null { + const worker = workerAuthority.getStore(); + if (worker) { + return worker.recordUserId === recordUserId ? worker : null; + } + + const auth = getEvent()?.getAuth(); + const requestAuthority = auth?.provider_work_authority; + if (requestAuthority) { + return requestAuthority.recordUserId === recordUserId + ? requestAuthority + : null; + } + + if (auth?.user_id) { + return auth.user_id === recordUserId + ? { + origin: "owner", + recordUserId, + actorUserId: recordUserId, + grantId: null, + } + : null; + } + + return { + origin: "system", + recordUserId, + actorUserId: null, + grantId: null, + }; +} + +export function mayEnqueueProviderWork( + authority: ProviderWorkAuthority | null, +): authority is ProviderWorkAuthority { + return authority !== null && authority.origin !== "delegate"; +} + +/** Recheck durable admission immediately before worker dispatch. */ +export async function mayDispatchProviderWork( + authority: ProviderWorkAuthority | undefined, +): Promise { + if (!authority) return false; + + switch (authority.origin) { + case "owner": + return ( + authority.actorUserId === authority.recordUserId && + authority.grantId === null + ); + case "system": + return authority.actorUserId === null && authority.grantId === null; + case "delegate": + return false; + case "guardian": { + if ( + authority.actorUserId === null || + authority.actorUserId === authority.recordUserId || + authority.grantId === null + ) { + return false; + } + const [record, grant] = await Promise.all([ + prisma.user.findUnique({ + where: { id: authority.recordUserId }, + select: { managedProfileAt: true }, + }), + findActiveGrant({ + grantorId: authority.recordUserId, + granteeId: authority.actorUserId, + }), + ]); + return ( + record?.managedProfileAt != null && + grant?.id === authority.grantId && + grant.access === "MANAGE" + ); + } + } +} + +export async function withProviderWorkAuthority( + authority: ProviderWorkAuthority, + work: () => Promise, +): Promise { + return workerAuthority.run(authority, work); +} diff --git a/tests/integration/sharing-provider-origin.test.ts b/tests/integration/sharing-provider-origin.test.ts index 5cfc145ac..b08304ba2 100644 --- a/tests/integration/sharing-provider-origin.test.ts +++ b/tests/integration/sharing-provider-origin.test.ts @@ -37,6 +37,13 @@ vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/feature-flags", () => ({ + getAssistantFlags: vi.fn().mockResolvedValue({ + briefing: true, + insightStatus: false, + }), +})); + import { ACCOUNT_SELECTOR_HEADER, apiHandler, @@ -46,9 +53,13 @@ import { hashToken } from "@/lib/auth/hmac"; import { providerCredentialPolicy, type ProviderWorkAuthority, + withProviderWorkAuthority, } from "@/lib/sharing/provider-work-authority"; -import { enqueueStatusGeneration } from "@/lib/jobs/insight-status-generate-shared"; import { runInsightStatusGenerate } from "@/lib/jobs/insight-status-generate"; +import type { InsightStatusGeneratePayload } from "@/lib/jobs/insight-status-generate"; +import { resolveProvider } from "@/lib/ai/provider"; +import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation"; +import { warmOneNarrative } from "@/lib/jobs/period-narrative-warm"; const PROVIDER_ORIGIN_KEY = "provider-origin-test-key"; let tokenNumber = 0; @@ -97,14 +108,17 @@ async function grantManage(input: { const enqueueFromSharedMutation: (request: NextRequest) => Promise = apiHandler(async () => { const { user } = await requireRecordAuth("manage", "medications"); - await enqueueStatusGeneration({ - userId: user.id, - metric: "weight", - locale: "en", - }); + await invalidateStatusInsightsForTypes(user.id, ["WEIGHT"]); return NextResponse.json({ data: { queued: true }, error: null }); }); +const resolveFromSharedRead: (request: NextRequest) => Promise = + apiHandler(async () => { + const { user } = await requireRecordAuth("manage", "record"); + const provider = await resolveProvider(user.id); + return NextResponse.json({ data: { provider: provider.type }, error: null }); + }); + function runMutation(): Promise { return enqueueFromSharedMutation( new NextRequest("http://localhost/api/test/provider-origin", { @@ -113,6 +127,12 @@ function runMutation(): Promise { ); } +function runSharedRead(): Promise { + return resolveFromSharedRead( + new NextRequest("http://localhost/api/test/provider-origin-read"), + ); +} + function authority(input: ProviderWorkAuthority): ProviderWorkAuthority { return input; } @@ -136,11 +156,12 @@ describe("sharing provider origin", () => { const response = await runMutation(); expect(response.status).toBe(200); - expect(send).toHaveBeenCalledTimes(1); - const payload = send.mock.calls[0]![1] as { - authority: ProviderWorkAuthority; - userId: string; - }; + expect(send).toHaveBeenCalledTimes(3); + const payload = ( + send.mock.calls as unknown as Array< + [string, InsightStatusGeneratePayload, unknown] + > + )[0]![1]; expect(payload.userId).toBe("provider-owner"); expect(payload.authority).toEqual( authority({ @@ -152,7 +173,15 @@ describe("sharing provider origin", () => { ); const generate = vi.fn().mockResolvedValue(undefined); - await runInsightStatusGenerate(payload, { weight: generate }); + await runInsightStatusGenerate(payload, { + general: vi.fn(), + "blood-pressure": vi.fn(), + weight: generate, + pulse: vi.fn(), + bmi: vi.fn(), + mood: vi.fn(), + "medication-compliance": vi.fn(), + }); expect(generate).toHaveBeenCalledWith("provider-owner", { locale: "en", @@ -192,13 +221,38 @@ describe("sharing provider origin", () => { grantId: "provider-delegated-grant", }), }, - { weight: generate }, + { + general: vi.fn(), + "blood-pressure": vi.fn(), + weight: generate, + pulse: vi.fn(), + bmi: vi.fn(), + mood: vi.fn(), + "medication-compliance": vi.fn(), + }, ); expect(generate).not.toHaveBeenCalled(); + + const narrativeGenerate = vi.fn().mockResolvedValue(null); + await warmOneNarrative( + { + userId: "provider-record", + period: "week", + authority: authority({ + origin: "delegate", + recordUserId: "provider-record", + actorUserId: "provider-delegate", + grantId: "provider-delegated-grant", + }), + }, + narrativeGenerate, + ); + + expect(narrativeGenerate).not.toHaveBeenCalled(); }); - it("keeps a managed-profile Guardian on operator/default provider configuration", () => { + it("keeps a managed-profile Guardian on operator/default provider configuration", async () => { const guardian = authority({ origin: "guardian", recordUserId: "managed-record", @@ -217,5 +271,90 @@ describe("sharing provider origin", () => { }), ), ).toBe("deny"); + + await getPrismaClient().user.create({ + data: { + id: "managed-record", + username: "managed-record", + email: "managed-record@example.test", + role: "USER", + managedProfileAt: new Date(), + aiProvider: "LOCAL", + aiBaseUrl: "https://guardian-byok.example.test/v1", + }, + }); + await createUser("guardian-actor"); + await grantManage({ + id: "guardian-grant", + recordUserId: "managed-record", + actorUserId: "guardian-actor", + }); + + const provider = await withProviderWorkAuthority(guardian, () => + resolveProvider("managed-record"), + ); + + expect(provider.type).toBe("none"); + }); + + it("returns no personal provider from the shared comprehensive-read origin", async () => { + await getPrismaClient().user.create({ + data: { + id: "provider-read-record", + username: "provider-read-record", + email: "provider-read-record@example.test", + role: "USER", + aiProvider: "LOCAL", + aiBaseUrl: "https://llm.example.test/v1", + }, + }); + await createUser("provider-read-delegate"); + await grantManage({ + id: "provider-read-grant", + recordUserId: "provider-read-record", + actorUserId: "provider-read-delegate", + }); + + headerJar.set( + "authorization", + `Bearer ${await mintToken("provider-read-record")}`, + ); + const owner = await runSharedRead(); + expect((await owner.json()).data.provider).not.toBe("none"); + + headerJar.set( + "authorization", + `Bearer ${await mintToken("provider-read-delegate")}`, + ); + headerJar.set(ACCOUNT_SELECTOR_HEADER, "provider-read-record"); + const delegated = await runSharedRead(); + + expect((await delegated.json()).data.provider).toBe("none"); + }); + + it("allows explicit system narrative work as the nightly positive control", async () => { + await createUser("provider-nightly"); + const narrativeGenerate = vi.fn().mockResolvedValue(null); + + await warmOneNarrative( + { + userId: "provider-nightly", + period: "week", + locale: "en", + authority: authority({ + origin: "system", + recordUserId: "provider-nightly", + actorUserId: null, + grantId: null, + }), + }, + narrativeGenerate, + ); + + expect(narrativeGenerate).toHaveBeenCalledWith("provider-nightly", { + period: "week", + locale: "en", + force: true, + }); }); });