fix: block delegated provider egress

Carry bounded work authority through queues and revalidate it at dispatch.\nResolve shared records without personal credentials.
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 01:49:33 +02:00
1 parent f7a06df2bc
commit e209db24f4
11 files changed
+466 -25

No files matched your search

+54 -1
View File
@@ -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<AIProvider | null> {
* 4. Nothing configured → NoProvider().
*/
export async function resolveProvider(userId: string): Promise<AIProvider> {
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<AIProvider> {
export async function resolveProviderChain(
userId: string,
): Promise<ProviderChainResolved[]> {
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<boolean> {
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<AIProvider | null> {
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);
+12
View File
@@ -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);
@@ -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" }),
);
});
+17 -2
View File
@@ -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<InsightStatusGeneratePayload, "authority">,
): Promise<void> {
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.
+21
View File
@@ -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<InsightStatusMetric, StatusGenerator> = {
export async function runInsightStatusGenerate(
payload: InsightStatusGeneratePayload,
generators: Record<InsightStatusMetric, StatusGenerator> = GENERATORS,
): Promise<void> {
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<InsightStatusMetric, StatusGenerator> = GENERATORS,
): Promise<void> {
// v1.8.7.1 — a `metric:<METRIC_ID>` scope routes to the generic
// HealthKit-metric generator rather than one of the seven specialised
+16
View File
@@ -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<void> {
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}`,
+14 -5
View File
@@ -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<NarrativeGenerateOutcome | null> {
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,
}),
);
}
+9
View File
@@ -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;
+4
View File
@@ -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)
+133
View File
@@ -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<ProviderWorkAuthority>();
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<boolean> {
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<T>(
authority: ProviderWorkAuthority,
work: () => Promise<T>,
): Promise<T> {
return workerAuthority.run(authority, work);
}
+153 -14
View File
@@ -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<Response> =
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<Response> =
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<Response> {
return enqueueFromSharedMutation(
new NextRequest("http://localhost/api/test/provider-origin", {
@@ -113,6 +127,12 @@ function runMutation(): Promise<Response> {
);
}
function runSharedRead(): Promise<Response> {
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,
});
});
});