From d57bf4eb971790f3636528e700cb6fbed3c82b69 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 19 Jun 2026 07:45:17 -0500 Subject: [PATCH] fix(fetchTask): send x-api-key so GET /api/tasks no longer 401s customer-prompt-task aborts with a misleading "Missing required accountId" because fetchTask() calls GET /api/tasks with no auth header. That endpoint became auth-gated in api#345, so it returns 401 -> fetchTask returns undefined -> accountId is undefined -> the worker bails at the guard and never generates the customer's check-in chat. - Send x-api-key: RECOUP_API_KEY on the request (mirrors generateChat.ts). - Guard a missing RECOUP_API_KEY with a clear log instead of a silent 401. - Log 401/403 as an explicit auth failure so it isn't collapsed into the downstream "Missing accountId" error. TDD: RED (no key header / no guard / generic 401 log) -> GREEN. Fixes part of recoupable/chat#1810. Requires the worker's RECOUP_API_KEY to be an admin-scoped key (see api PR) so the cross-account task lookup resolves. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/recoup/__tests__/fetchTask.test.ts | 88 ++++++++++++++++++++++++++ src/recoup/fetchTask.ts | 21 +++++- 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 src/recoup/__tests__/fetchTask.test.ts diff --git a/src/recoup/__tests__/fetchTask.test.ts b/src/recoup/__tests__/fetchTask.test.ts new file mode 100644 index 0000000..d065184 --- /dev/null +++ b/src/recoup/__tests__/fetchTask.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +// Shared logger object the mocked module returns; assertions run against it. +const logger = { warn: vi.fn(), error: vi.fn(), log: vi.fn() }; +vi.mock("@trigger.dev/sdk/v3", () => ({ logger })); + +const ORIGINAL_KEY = process.env.RECOUP_API_KEY; + +const validTask = { + id: "t1", + title: "Daily check-in", + prompt: "Draft a check-in", + schedule: "0 9 * * *", + account_id: "acc_owner", + artist_account_id: "artist_1", + enabled: true, +}; + +describe("fetchTask", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + process.env.RECOUP_API_KEY = "test-key"; + }); + + afterEach(() => { + if (ORIGINAL_KEY === undefined) delete process.env.RECOUP_API_KEY; + else process.env.RECOUP_API_KEY = ORIGINAL_KEY; + }); + + it("sends the x-api-key header from RECOUP_API_KEY on the GET request", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ status: "success", tasks: [validTask] }), + }); + + const { fetchTask } = await import("../fetchTask"); + const result = await fetchTask("351fc5b2"); + + expect(mockFetch).toHaveBeenCalledWith( + "https://recoup-api.vercel.app/api/tasks?id=351fc5b2", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + "Content-Type": "application/json", + "x-api-key": "test-key", + }), + }), + ); + expect(result).toEqual({ + prompt: "Draft a check-in", + accountId: "acc_owner", + artistId: "artist_1", + model: undefined, + }); + }); + + it("returns undefined and logs an error when RECOUP_API_KEY is missing", async () => { + delete process.env.RECOUP_API_KEY; + + const { fetchTask } = await import("../fetchTask"); + const result = await fetchTask("351fc5b2"); + + expect(result).toBeUndefined(); + expect(mockFetch).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); + }); + + it("logs an auth failure (not a generic error) when the API responds 401", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + }); + + const { fetchTask } = await import("../fetchTask"); + const result = await fetchTask("351fc5b2"); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("auth"), + expect.objectContaining({ status: 401 }), + ); + }); +}); diff --git a/src/recoup/fetchTask.ts b/src/recoup/fetchTask.ts index 4799da5..5233e40 100644 --- a/src/recoup/fetchTask.ts +++ b/src/recoup/fetchTask.ts @@ -1,6 +1,6 @@ import { logger } from "@trigger.dev/sdk/v3"; import { z } from "zod"; -import { NEW_API_BASE_URL } from "../consts"; +import { NEW_API_BASE_URL, RECOUP_API_KEY } from "../consts"; import { type ChatConfig } from "../schemas/chatSchema"; // Zod schema for validating task response from Recoup Tasks API @@ -36,6 +36,13 @@ export async function fetchTask( return undefined; } + // GET /api/tasks is auth-gated; without a key it returns 401 and the task + // would silently abort downstream with a misleading "Missing accountId". + if (!RECOUP_API_KEY) { + logger.error("Missing RECOUP_API_KEY environment variable", { externalId }); + return undefined; + } + const tasksApiUrl = `${NEW_API_BASE_URL}/api/tasks`; try { @@ -43,10 +50,22 @@ export async function fetchTask( method: "GET", headers: { "Content-Type": "application/json", + "x-api-key": RECOUP_API_KEY, }, }); if (!response.ok) { + // Surface auth failures distinctly so they aren't mistaken for "task not + // found" or collapsed into the downstream "Missing accountId" error. + if (response.status === 401 || response.status === 403) { + logger.error("Recoup Tasks API auth failure", { + externalId, + status: response.status, + statusText: response.statusText, + }); + return undefined; + } + logger.error("Recoup Tasks API error", { externalId, status: response.status,